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
+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 {