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.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',
+5
View File
@@ -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': '一般ユーザー',
+5
View File
@@ -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': 'Пользователь',
+5
View File
@@ -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ı',
+5
View File
@@ -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': '普通用户',
+78 -17
View File
@@ -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>
);
+17
View File
@@ -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 }
];
+1
View File
@@ -5,6 +5,7 @@ export interface FormData {
full_name: string;
password: string;
is_active?: boolean;
source?: string;
}
export interface ListItem extends FormData {