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.active.description': 'Enable or disable this user account',
|
||||||
'users.form.fullname': 'Full Name',
|
'users.form.fullname': 'Full Name',
|
||||||
'users.form.source': 'Source',
|
'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.table.user': 'users',
|
||||||
'users.form.admin': 'Admin',
|
'users.form.admin': 'Admin',
|
||||||
'users.form.user': 'User',
|
'users.form.user': 'User',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export default {
|
|||||||
'このユーザーアカウントを有効または無効にする',
|
'このユーザーアカウントを有効または無効にする',
|
||||||
'users.form.fullname': 'フルネーム',
|
'users.form.fullname': 'フルネーム',
|
||||||
'users.form.source': 'ソース',
|
'users.form.source': 'ソース',
|
||||||
|
'users.form.source.local': 'ローカル',
|
||||||
|
'users.form.source.tip.switchToLocal':
|
||||||
|
'ローカルに切り替えるには新しいパスワードが必要です。以後、ユーザーは標準のログインフォームからサインインします。',
|
||||||
|
'users.form.source.tip.switchToExternal':
|
||||||
|
'外部ソースに切り替えるとユーザーのローカルパスワードが削除され、設定済みの ID プロバイダーからサインインするようになります。',
|
||||||
'users.table.user': 'ユーザー',
|
'users.table.user': 'ユーザー',
|
||||||
'users.form.admin': '管理者',
|
'users.form.admin': '管理者',
|
||||||
'users.form.user': '一般ユーザー',
|
'users.form.user': '一般ユーザー',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export default {
|
|||||||
'Включить или отключить эту учетную запись пользователя',
|
'Включить или отключить эту учетную запись пользователя',
|
||||||
'users.form.fullname': 'Полное имя',
|
'users.form.fullname': 'Полное имя',
|
||||||
'users.form.source': 'Источник',
|
'users.form.source': 'Источник',
|
||||||
|
'users.form.source.local': 'Локальный',
|
||||||
|
'users.form.source.tip.switchToLocal':
|
||||||
|
'Переключение на «Локальный» требует ввода нового пароля. После этого пользователь будет входить через стандартную форму входа.',
|
||||||
|
'users.form.source.tip.switchToExternal':
|
||||||
|
'Переключение на внешний источник удаляет локальный пароль пользователя. После этого вход будет выполняться через настроенного провайдера идентификации.',
|
||||||
'users.table.user': 'пользователи',
|
'users.table.user': 'пользователи',
|
||||||
'users.form.admin': 'Администратор',
|
'users.form.admin': 'Администратор',
|
||||||
'users.form.user': 'Пользователь',
|
'users.form.user': 'Пользователь',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export default {
|
|||||||
'Bu kullanıcı hesabını etkinleştir veya devre dışı bırak',
|
'Bu kullanıcı hesabını etkinleştir veya devre dışı bırak',
|
||||||
'users.form.fullname': 'Tam Ad',
|
'users.form.fullname': 'Tam Ad',
|
||||||
'users.form.source': 'Kaynak',
|
'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.table.user': 'kullanıcılar',
|
||||||
'users.form.admin': 'Yönetici',
|
'users.form.admin': 'Yönetici',
|
||||||
'users.form.user': 'Kullanıcı',
|
'users.form.user': 'Kullanıcı',
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export default {
|
|||||||
'users.form.active.description': '启用或禁用此用户账户',
|
'users.form.active.description': '启用或禁用此用户账户',
|
||||||
'users.form.fullname': '全名',
|
'users.form.fullname': '全名',
|
||||||
'users.form.source': '来源',
|
'users.form.source': '来源',
|
||||||
|
'users.form.source.local': '本地',
|
||||||
|
'users.form.source.tip.switchToLocal':
|
||||||
|
'切换到本地需要设置新密码,之后用户将通过标准登录表单登录。',
|
||||||
|
'users.form.source.tip.switchToExternal':
|
||||||
|
'切换到外部来源会清除该用户的本地密码,之后用户将通过所配置的身份提供商登录。',
|
||||||
'users.table.user': '用户',
|
'users.table.user': '用户',
|
||||||
'users.form.admin': '管理员',
|
'users.form.admin': '管理员',
|
||||||
'users.form.user': '普通用户',
|
'users.form.user': '普通用户',
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
import { useIntl, useModel } from '@umijs/max';
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
import { Form, Select } from 'antd';
|
import { Form, Select } from 'antd';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { UserRoles, UserRolesOptions } from '../config';
|
import {
|
||||||
|
AuthSourceOptions,
|
||||||
|
AuthSources,
|
||||||
|
UserRoles,
|
||||||
|
UserRolesOptions
|
||||||
|
} from '../config';
|
||||||
import { FormData, ListItem } from '../config/types';
|
import { FormData, ListItem } from '../config/types';
|
||||||
|
|
||||||
type AddModalProps = {
|
type AddModalProps = {
|
||||||
@@ -34,18 +39,43 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { loading, guard, run, release } = useSubmitLock();
|
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 = () => {
|
const initFormValue = () => {
|
||||||
if (action === PageAction.EDIT && open) {
|
if (action === PageAction.EDIT && open) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...data,
|
...data,
|
||||||
is_admin: data?.is_admin ? UserRoles.ADMIN : UserRoles.USER,
|
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) {
|
} else if (action === PageAction.CREATE && open) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
is_admin: UserRoles.USER,
|
is_admin: UserRoles.USER,
|
||||||
is_active: true
|
is_active: true,
|
||||||
|
source: AuthSources.LOCAL
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,11 +185,41 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Form.Item<FormData>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
<Form.Item<FormData>
|
||||||
name="password"
|
name="password"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: action === PageAction.CREATE,
|
required: passwordRequired,
|
||||||
pattern: PasswordReg,
|
pattern: PasswordReg,
|
||||||
message: intl.formatMessage({ id: 'users.form.rule.password' })
|
message: intl.formatMessage({ id: 'users.form.rule.password' })
|
||||||
}
|
}
|
||||||
@@ -168,10 +228,11 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
<CInput.Password
|
<CInput.Password
|
||||||
autoComplete={'new-password'}
|
autoComplete={'new-password'}
|
||||||
label={intl.formatMessage({ id: 'common.form.password' })}
|
label={intl.formatMessage({ id: 'common.form.password' })}
|
||||||
required={action === PageAction.CREATE}
|
required={passwordRequired}
|
||||||
onPressEnter={handleSubmit}
|
onPressEnter={handleSubmit}
|
||||||
></CInput.Password>
|
></CInput.Password>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</FormDrawer>
|
</FormDrawer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,3 +7,20 @@ export const UserRolesOptions = [
|
|||||||
{ label: 'users.form.admin', value: UserRoles.ADMIN },
|
{ label: 'users.form.admin', value: UserRoles.ADMIN },
|
||||||
{ label: 'users.form.user', value: UserRoles.USER }
|
{ 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;
|
full_name: string;
|
||||||
password: string;
|
password: string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListItem extends FormData {
|
export interface ListItem extends FormData {
|
||||||
|
|||||||
Reference in New Issue
Block a user