From 350f398cde0ec90396b37d34eaa1a9f9be647fc8 Mon Sep 17 00:00:00 2001 From: gitlawr Date: Tue, 30 Jun 2026 19:10:54 +0800 Subject: [PATCH] feat(users): add authentication source dropdown to the user form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/locales/en-US/users.ts | 5 ++ src/locales/ja-JP/users.ts | 5 ++ src/locales/ru-RU/users.ts | 5 ++ src/locales/tr-TR/users.ts | 5 ++ src/locales/zh-CN/users.ts | 5 ++ src/pages/users/components/add-modal.tsx | 95 +++++++++++++++++++----- src/pages/users/config/index.ts | 17 +++++ src/pages/users/config/types.ts | 1 + 8 files changed, 121 insertions(+), 17 deletions(-) diff --git a/src/locales/en-US/users.ts b/src/locales/en-US/users.ts index f08bd681..b5f26683 100644 --- a/src/locales/en-US/users.ts +++ b/src/locales/en-US/users.ts @@ -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', diff --git a/src/locales/ja-JP/users.ts b/src/locales/ja-JP/users.ts index 894cbb68..a985f952 100644 --- a/src/locales/ja-JP/users.ts +++ b/src/locales/ja-JP/users.ts @@ -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': '一般ユーザー', diff --git a/src/locales/ru-RU/users.ts b/src/locales/ru-RU/users.ts index 6e7c3af0..24886072 100644 --- a/src/locales/ru-RU/users.ts +++ b/src/locales/ru-RU/users.ts @@ -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': 'Пользователь', diff --git a/src/locales/tr-TR/users.ts b/src/locales/tr-TR/users.ts index ae892426..ef360904 100644 --- a/src/locales/tr-TR/users.ts +++ b/src/locales/tr-TR/users.ts @@ -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ı', diff --git a/src/locales/zh-CN/users.ts b/src/locales/zh-CN/users.ts index 0b900b18..a46eaff9 100644 --- a/src/locales/zh-CN/users.ts +++ b/src/locales/zh-CN/users.ts @@ -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': '普通用户', diff --git a/src/pages/users/components/add-modal.tsx b/src/pages/users/components/add-modal.tsx index 63aa8d3b..a3fd4d62 100644 --- a/src/pages/users/components/add-modal.tsx +++ b/src/pages/users/components/add-modal.tsx @@ -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 = ({ 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 = ({ - 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 + } > - + + + {showPassword && ( + + name="password" + rules={[ + { + required: passwordRequired, + pattern: PasswordReg, + message: intl.formatMessage({ id: 'users.form.rule.password' }) + } + ]} + > + + + )} ); diff --git a/src/pages/users/config/index.ts b/src/pages/users/config/index.ts index 9a40a5e1..60d5d45f 100644 --- a/src/pages/users/config/index.ts +++ b/src/pages/users/config/index.ts @@ -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 } +]; diff --git a/src/pages/users/config/types.ts b/src/pages/users/config/types.ts index 3ade8bd2..134a28df 100644 --- a/src/pages/users/config/types.ts +++ b/src/pages/users/config/types.ts @@ -5,6 +5,7 @@ export interface FormData { full_name: string; password: string; is_active?: boolean; + source?: string; } export interface ListItem extends FormData {