feat(users): add authentication source dropdown to the user form
The add / edit user drawer now exposes a Source select (Local / OIDC /
SAML / CAS) so an admin can flip an existing account between Local
password and an external IdP without touching the database. Mirrors
the matching `PUT /v1/users/{id}` change on the backend.
Password field follows the selected source:
* Hidden when source != Local — those users authenticate via the IdP
and a local password row would be a /login bypass.
* Required when CREATE-with-Local, or when EDIT is switching an SSO
user back to Local (the backend rejects SSO -> Local without a
fresh password to avoid locking the user out of /login).
* Optional when editing an already-Local user, matching today's
behaviour.
A switch in EDIT mode surfaces a tip explaining the side effect
(password cleared / new password required) so the consequence isn't
hidden. The Source select is disabled on self-edit — same guard the
role column already uses — so an admin can't lock themselves out by
flipping their own row to an external source.
Strings are added to all five locales; the IdP protocol acronyms
(OIDC / SAML / CAS) render verbatim and don't need translation keys.
This commit is contained in:
@@ -12,6 +12,11 @@ export default {
|
||||
'users.form.active.description': 'Enable or disable this user account',
|
||||
'users.form.fullname': 'Full Name',
|
||||
'users.form.source': 'Source',
|
||||
'users.form.source.local': 'Local',
|
||||
'users.form.source.tip.switchToLocal':
|
||||
'Switching to Local requires a new password. The user will sign in via the standard login form.',
|
||||
'users.form.source.tip.switchToExternal':
|
||||
"Switching to an external source clears the user's local password. They will sign in via the configured identity provider.",
|
||||
'users.table.user': 'users',
|
||||
'users.form.admin': 'Admin',
|
||||
'users.form.user': 'User',
|
||||
|
||||
@@ -13,6 +13,11 @@ export default {
|
||||
'このユーザーアカウントを有効または無効にする',
|
||||
'users.form.fullname': 'フルネーム',
|
||||
'users.form.source': 'ソース',
|
||||
'users.form.source.local': 'ローカル',
|
||||
'users.form.source.tip.switchToLocal':
|
||||
'ローカルに切り替えるには新しいパスワードが必要です。以後、ユーザーは標準のログインフォームからサインインします。',
|
||||
'users.form.source.tip.switchToExternal':
|
||||
'外部ソースに切り替えるとユーザーのローカルパスワードが削除され、設定済みの ID プロバイダーからサインインするようになります。',
|
||||
'users.table.user': 'ユーザー',
|
||||
'users.form.admin': '管理者',
|
||||
'users.form.user': '一般ユーザー',
|
||||
|
||||
@@ -13,6 +13,11 @@ export default {
|
||||
'Включить или отключить эту учетную запись пользователя',
|
||||
'users.form.fullname': 'Полное имя',
|
||||
'users.form.source': 'Источник',
|
||||
'users.form.source.local': 'Локальный',
|
||||
'users.form.source.tip.switchToLocal':
|
||||
'Переключение на «Локальный» требует ввода нового пароля. После этого пользователь будет входить через стандартную форму входа.',
|
||||
'users.form.source.tip.switchToExternal':
|
||||
'Переключение на внешний источник удаляет локальный пароль пользователя. После этого вход будет выполняться через настроенного провайдера идентификации.',
|
||||
'users.table.user': 'пользователи',
|
||||
'users.form.admin': 'Администратор',
|
||||
'users.form.user': 'Пользователь',
|
||||
|
||||
@@ -13,6 +13,11 @@ export default {
|
||||
'Bu kullanıcı hesabını etkinleştir veya devre dışı bırak',
|
||||
'users.form.fullname': 'Tam Ad',
|
||||
'users.form.source': 'Kaynak',
|
||||
'users.form.source.local': 'Yerel',
|
||||
'users.form.source.tip.switchToLocal':
|
||||
'Yerel kaynağa geçmek yeni bir parola gerektirir. Kullanıcı bundan sonra standart oturum açma formunu kullanır.',
|
||||
'users.form.source.tip.switchToExternal':
|
||||
'Harici bir kaynağa geçmek kullanıcının yerel parolasını siler. Kullanıcı bundan sonra yapılandırılmış kimlik sağlayıcı üzerinden oturum açar.',
|
||||
'users.table.user': 'kullanıcılar',
|
||||
'users.form.admin': 'Yönetici',
|
||||
'users.form.user': 'Kullanıcı',
|
||||
|
||||
@@ -12,6 +12,11 @@ export default {
|
||||
'users.form.active.description': '启用或禁用此用户账户',
|
||||
'users.form.fullname': '全名',
|
||||
'users.form.source': '来源',
|
||||
'users.form.source.local': '本地',
|
||||
'users.form.source.tip.switchToLocal':
|
||||
'切换到本地需要设置新密码,之后用户将通过标准登录表单登录。',
|
||||
'users.form.source.tip.switchToExternal':
|
||||
'切换到外部来源会清除该用户的本地密码,之后用户将通过所配置的身份提供商登录。',
|
||||
'users.table.user': '用户',
|
||||
'users.form.admin': '管理员',
|
||||
'users.form.user': '普通用户',
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Form, Select } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { UserRoles, UserRolesOptions } from '../config';
|
||||
import {
|
||||
AuthSourceOptions,
|
||||
AuthSources,
|
||||
UserRoles,
|
||||
UserRolesOptions
|
||||
} from '../config';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
|
||||
type AddModalProps = {
|
||||
@@ -34,18 +39,43 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const [form] = Form.useForm();
|
||||
const intl = useIntl();
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
const selectedSource = Form.useWatch('source', form) ?? AuthSources.LOCAL;
|
||||
const existingSource = data?.source ?? AuthSources.LOCAL;
|
||||
// Local is the only source that uses a local password row. For an
|
||||
// existing SSO user (or a brand-new SSO row at create time) the
|
||||
// password field is hidden entirely.
|
||||
const showPassword = selectedSource === AuthSources.LOCAL;
|
||||
// Password is required when there's no usable credential after
|
||||
// submit: create-Local always, or edit-Local that is *switching*
|
||||
// from an SSO source (the backend rejects SSO → Local without a
|
||||
// fresh password to avoid locking the user out of /login). Editing
|
||||
// a user who was already Local can leave it blank.
|
||||
const passwordRequired =
|
||||
showPassword &&
|
||||
(action === PageAction.CREATE || existingSource !== AuthSources.LOCAL);
|
||||
// Switching the source is a sensitive operation — surface the
|
||||
// consequence to the admin so they're not surprised that flipping
|
||||
// to an IdP silently invalidates the user's local password.
|
||||
const sourceSwitchTipId =
|
||||
action === PageAction.EDIT && selectedSource !== existingSource
|
||||
? selectedSource === AuthSources.LOCAL
|
||||
? 'users.form.source.tip.switchToLocal'
|
||||
: 'users.form.source.tip.switchToExternal'
|
||||
: null;
|
||||
|
||||
const initFormValue = () => {
|
||||
if (action === PageAction.EDIT && open) {
|
||||
form.setFieldsValue({
|
||||
...data,
|
||||
is_admin: data?.is_admin ? UserRoles.ADMIN : UserRoles.USER,
|
||||
is_active: !!data?.is_active
|
||||
is_active: !!data?.is_active,
|
||||
source: data?.source || AuthSources.LOCAL
|
||||
});
|
||||
} else if (action === PageAction.CREATE && open) {
|
||||
form.setFieldsValue({
|
||||
is_admin: UserRoles.USER,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
source: AuthSources.LOCAL
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -156,22 +186,53 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
</div>
|
||||
|
||||
<Form.Item<FormData>
|
||||
name="password"
|
||||
rules={[
|
||||
{
|
||||
required: action === PageAction.CREATE,
|
||||
pattern: PasswordReg,
|
||||
message: intl.formatMessage({ id: 'users.form.rule.password' })
|
||||
}
|
||||
]}
|
||||
name="source"
|
||||
rules={[{ required: false }]}
|
||||
// The select always carries a value (initialised to the
|
||||
// user's current source), so the dropdown alone doesn't
|
||||
// signal intent — ``sourceSwitchTipId`` adds an inline
|
||||
// explanation of the side effect whenever the selected
|
||||
// value diverges from the stored one.
|
||||
extra={
|
||||
sourceSwitchTipId
|
||||
? intl.formatMessage({ id: sourceSwitchTipId })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CInput.Password
|
||||
autoComplete={'new-password'}
|
||||
label={intl.formatMessage({ id: 'common.form.password' })}
|
||||
required={action === PageAction.CREATE}
|
||||
onPressEnter={handleSubmit}
|
||||
></CInput.Password>
|
||||
<SealSelect
|
||||
label={intl.formatMessage({ id: 'users.form.source' })}
|
||||
// Self-edit guard: flipping your own row to an external
|
||||
// source clears your own local password — if SSO is
|
||||
// misconfigured or the IdP can't reach you, that locks
|
||||
// you out of /login with no recovery path.
|
||||
disabled={
|
||||
!!data?.id &&
|
||||
data.id === initialState?.currentUser?.id &&
|
||||
action === PageAction.EDIT
|
||||
}
|
||||
options={AuthSourceOptions}
|
||||
></SealSelect>
|
||||
</Form.Item>
|
||||
|
||||
{showPassword && (
|
||||
<Form.Item<FormData>
|
||||
name="password"
|
||||
rules={[
|
||||
{
|
||||
required: passwordRequired,
|
||||
pattern: PasswordReg,
|
||||
message: intl.formatMessage({ id: 'users.form.rule.password' })
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CInput.Password
|
||||
autoComplete={'new-password'}
|
||||
label={intl.formatMessage({ id: 'common.form.password' })}
|
||||
required={passwordRequired}
|
||||
onPressEnter={handleSubmit}
|
||||
></CInput.Password>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
);
|
||||
|
||||
@@ -7,3 +7,20 @@ export const UserRolesOptions = [
|
||||
{ label: 'users.form.admin', value: UserRoles.ADMIN },
|
||||
{ label: 'users.form.user', value: UserRoles.USER }
|
||||
];
|
||||
|
||||
export const AuthSources = {
|
||||
LOCAL: 'Local',
|
||||
OIDC: 'OIDC',
|
||||
SAML: 'SAML',
|
||||
CAS: 'CAS'
|
||||
};
|
||||
|
||||
// ``locale: true`` on Local only — the IdP names (OIDC / SAML / CAS)
|
||||
// are protocol acronyms and stay unchanged across locales, so they
|
||||
// render verbatim and don't get a translation key.
|
||||
export const AuthSourceOptions = [
|
||||
{ label: 'users.form.source.local', value: AuthSources.LOCAL, locale: true },
|
||||
{ label: AuthSources.OIDC, value: AuthSources.OIDC },
|
||||
{ label: AuthSources.SAML, value: AuthSources.SAML },
|
||||
{ label: AuthSources.CAS, value: AuthSources.CAS }
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface FormData {
|
||||
full_name: string;
|
||||
password: string;
|
||||
is_active?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ListItem extends FormData {
|
||||
|
||||
Reference in New Issue
Block a user