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:
gitlawr
2026-07-01 15:39:10 +08:00
committed by jialin
parent 8a15172316
commit 350f398cde
8 changed files with 121 additions and 17 deletions
+5
View File
@@ -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',
+5
View File
@@ -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': '一般ユーザー',
+5
View File
@@ -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': 'Пользователь',
+5
View File
@@ -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ı',
+5
View File
@@ -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': '普通用户',
+78 -17
View File
@@ -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
}); });
} }
}; };
@@ -156,22 +186,53 @@ const AddModal: React.FC<AddModalProps> = ({
</div> </div>
<Form.Item<FormData> <Form.Item<FormData>
name="password" name="source"
rules={[ rules={[{ required: false }]}
{ // The select always carries a value (initialised to the
required: action === PageAction.CREATE, // user's current source), so the dropdown alone doesn't
pattern: PasswordReg, // signal intent — ``sourceSwitchTipId`` adds an inline
message: intl.formatMessage({ id: 'users.form.rule.password' }) // explanation of the side effect whenever the selected
} // value diverges from the stored one.
]} extra={
sourceSwitchTipId
? intl.formatMessage({ id: sourceSwitchTipId })
: undefined
}
> >
<CInput.Password <SealSelect
autoComplete={'new-password'} label={intl.formatMessage({ id: 'users.form.source' })}
label={intl.formatMessage({ id: 'common.form.password' })} // Self-edit guard: flipping your own row to an external
required={action === PageAction.CREATE} // source clears your own local password — if SSO is
onPressEnter={handleSubmit} // misconfigured or the IdP can't reach you, that locks
></CInput.Password> // you out of /login with no recovery path.
disabled={
!!data?.id &&
data.id === initialState?.currentUser?.id &&
action === PageAction.EDIT
}
options={AuthSourceOptions}
></SealSelect>
</Form.Item> </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> </Form>
</FormDrawer> </FormDrawer>
); );
+17
View File
@@ -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 }
];
+1
View File
@@ -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 {