feat(auth): improve system settings interactions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-10 16:25:12 +08:00
parent f630519625
commit 44a311ea80
8 changed files with 337 additions and 93 deletions

View file

@ -15,6 +15,8 @@
“系统配置”页面分为“本地认证”和“外部账号首次授权”两部分。`system_setting` 保存少量经过代码注册、定型和校验的运行设置,不提供任意键值编辑。首批设置键 `auth.local` 的 JSON 对象包含 `passwordLoginEnabled` 和 `selfRegistrationEnabled`,两者默认均为 `true`。同一行更新这组设置,并使用版本号进行条件更新,避免并发管理操作互相覆盖。
页面内两个开关先形成未保存草稿,由管理员统一保存或放弃。关闭密码登录时,在站内确认框中展示管理员可能无法重新登录的后果,确认后才提交;取消确认保留草稿,不修改服务端。首次授权规则的角色变更也需显式保存,停用规则使用站内确认框。失败时保留可见的错误提示和原编辑内容。
建议表结构:`id BIGSERIAL PRIMARY KEY`、`setting_key VARCHAR(128) UNIQUE NOT NULL`、`value_json JSONB NOT NULL`、`version BIGINT NOT NULL`、`created_at/updated_at TIMESTAMP NOT NULL`、`updated_by VARCHAR(128) NULL`。数据库约束验证 JSON 为对象;应用按设置键验证完整字段、类型和允许值。`id` 可用作现有 `audit_log.target_id`。审计记录操作者、旧值、新值和时间;不得把秘密配置写入设置或审计详情。
认证路径直接读取数据库中已持久化的安全开关。首版不使用单节点内存缓存,以免多副本部署时设置失效不一致;读取失败应返回服务错误,不能以默认“开启”放行。前端只读取可公开的认证能力投影,不暴露通用设置读写接口。

View file

@ -1675,6 +1675,25 @@
"goToPage": "Go to page {{page}}"
},
"systemConfig": {
"intro": "Manage sign-in methods and roles granted when new external accounts are created.",
"passwordLoginHelp": "When disabled, local accounts cannot sign in with a password again. Existing sessions remain valid until they expire.",
"selfRegistrationHelp": "When disabled, visitors cannot create local accounts. Existing accounts are unaffected.",
"unsaved": "Unsaved changes",
"changedElsewhere": "Another administrator changed these settings. Review the updated values before saving again.",
"discard": "Discard changes",
"saveChanges": "Save changes",
"saving": "Saving…",
"loading": "Loading…",
"lockoutTitle": "Disable local password sign-in?",
"confirmDisableLogin": "Disable sign-in",
"ruleIntro": "Choose a platform role for a new external account after it passes access checks.",
"ruleApplies": "Applies only to newly created external accounts after access checks. Grant roles to existing users in User Management.",
"ruleNoMerge": "Local and external accounts with the same email are not merged. The role goes only to the new external account.",
"ruleUnavailable": "Feishu and DingTalk do not currently provide verified email, so they cannot use this rule.",
"providerExample": "For example, github",
"existingRules": "Existing rules",
"noRules": "No rules yet. Add one using the form above.",
"disableTitle": "Disable this role rule?",
"title": "System settings",
"localAuth": "Local accounts",
"lockoutHelp": "Before disabling password login, confirm that external sign-in and super admin access work.",
@ -1682,10 +1701,9 @@
"passwordLogin": "Allow local password login",
"selfRegistration": "Allow local account registration",
"registrationRequiresLogin": "Password login is off, so registration is currently unavailable. The registration setting is retained.",
"lockoutWarning": "Admins who rely only on passwords will not be able to sign in again. Continue?",
"lockoutWarning": "Admins who rely only on passwords cannot sign in again after this change. Confirm external sign-in and super admin access first. Existing sessions remain valid until they expire.",
"initialRoleRules": "Initial external account role rules",
"ruleHelp": "Applies only when a new external account is created after passing the existing access policy. A local account with the same email is not merged. Grant existing users a role in User Management. Feishu and DingTalk do not currently provide verified email, so these rules are unavailable for them.",
"provider": "Identity provider code",
"provider": "External sign-in provider",
"email": "Verified email",
"role": "Platform role",
"addRule": "Add rule",
@ -1797,6 +1815,7 @@
},
"dialog": {
"confirm": "Confirm",
"processing": "Processing…",
"cancel": "Cancel",
"delete": "Delete",
"close": "Close"

View file

@ -1601,6 +1601,7 @@
},
"dialog": {
"confirm": "Подтвердить",
"processing": "Обработка…",
"cancel": "Отмена",
"delete": "Удалить",
"close": "Закрыть"
@ -1994,6 +1995,25 @@
"folderHint": "Или выберите папку для упаковки и загрузки"
},
"systemConfig": {
"intro": "Настройте способы входа и роли для новых внешних учётных записей.",
"passwordLoginHelp": "После отключения локальные учётные записи не смогут снова войти по паролю. Текущие сеансы действуют до истечения срока.",
"selfRegistrationHelp": "После отключения посетители не смогут создавать локальные учётные записи. Существующие записи не затронуты.",
"unsaved": "Есть несохранённые изменения",
"changedElsewhere": "Настройки изменены другим администратором. Проверьте новые значения перед повторным сохранением.",
"discard": "Отменить изменения",
"saveChanges": "Сохранить изменения",
"saving": "Сохранение…",
"loading": "Загрузка…",
"lockoutTitle": "Отключить вход по локальному паролю?",
"confirmDisableLogin": "Отключить вход",
"ruleIntro": "Назначьте роль новой внешней учётной записи после проверки доступа.",
"ruleApplies": "Действует только для новых внешних учётных записей после проверки доступа. Существующим пользователям назначайте роли в управлении пользователями.",
"ruleNoMerge": "Локальные и внешние учётные записи с одинаковой почтой не объединяются. Роль получает только новая внешняя запись.",
"ruleUnavailable": "Feishu и DingTalk пока не предоставляют подтверждённую почту, поэтому правило для них недоступно.",
"providerExample": "Например, github",
"existingRules": "Существующие правила",
"noRules": "Правил пока нет. Добавьте правило с помощью формы выше.",
"disableTitle": "Отключить правило назначения роли?",
"title": "Системные настройки",
"localAuth": "Локальные учётные записи",
"lockoutHelp": "Перед отключением входа по паролю проверьте внешний вход и доступ суперадминистратора.",
@ -2001,10 +2021,9 @@
"passwordLogin": "Разрешить вход по локальному паролю",
"selfRegistration": "Разрешить самостоятельную регистрацию",
"registrationRequiresLogin": "Вход по паролю отключён, поэтому регистрация сейчас недоступна. Настройка регистрации сохранена.",
"lockoutWarning": "Администраторы, использующие только пароль, не смогут войти снова. Продолжить?",
"lockoutWarning": "Администраторы, использующие только пароль, не смогут войти снова. Сначала проверьте внешний вход и доступ суперадминистратора. Текущие сеансы действуют до истечения срока.",
"initialRoleRules": "Правила первоначального назначения ролей",
"ruleHelp": "Правило действует только при создании новой внешней учётной записи после проверки доступа. Локальная запись с тем же адресом не объединяется. Существующим пользователям назначайте роль в управлении пользователями. Feishu и DingTalk сейчас не подтверждают адрес почты, поэтому такие правила для них недоступны.",
"provider": "Код поставщика удостоверений",
"provider": "Источник внешнего входа",
"email": "Подтверждённая почта",
"role": "Роль платформы",
"addRule": "Добавить правило",

View file

@ -1674,6 +1674,25 @@
"goToPage": "第 {{page}} 页"
},
"systemConfig": {
"intro": "管理登录方式,以及新外部账号首次进入时的角色授权。",
"passwordLoginHelp": "关闭后,所有本地账号都不能再用密码重新登录。已有会话按原有效期继续使用。",
"selfRegistrationHelp": "关闭后,访客不能自行创建本地账号;已有账号不受影响。",
"unsaved": "有未保存的修改",
"changedElsewhere": "配置已由其他管理员修改。页面已更新,请核对后重新保存。",
"discard": "放弃修改",
"saveChanges": "保存修改",
"saving": "保存中…",
"loading": "加载中…",
"lockoutTitle": "确认关闭本地密码登录?",
"confirmDisableLogin": "确认关闭",
"ruleIntro": "为通过准入审核的新外部账号,预先指定平台角色。",
"ruleApplies": "仅对通过现有准入策略、首次创建的外部账号生效;已有账号请到用户管理授权。",
"ruleNoMerge": "同邮箱的本地账号与外部账号不会合并,角色只授给新外部账号。",
"ruleUnavailable": "飞书和钉钉目前不提供已验证邮箱,暂不能配置此规则。",
"providerExample": "例如 github",
"existingRules": "已有规则",
"noRules": "暂无规则。填写上方信息后添加。",
"disableTitle": "停用授权规则?",
"title": "系统配置",
"localAuth": "本地账号",
"lockoutHelp": "关闭密码登录前,请先确认外部身份登录和超级管理员权限可用。",
@ -1681,10 +1700,9 @@
"passwordLogin": "允许本地密码登录",
"selfRegistration": "允许自行注册本地账号",
"registrationRequiresLogin": "密码登录已关闭,当前无法自行注册;注册设置会保留。",
"lockoutWarning": "关闭密码登录后,原本仅靠密码登录的管理员将无法重新登录。确定继续吗?",
"lockoutWarning": "关闭后,只靠密码登录的管理员将无法重新登录。请先确认外部登录和超级管理员权限可用;已有会话按原有效期结束。",
"initialRoleRules": "外部账号首次授权规则",
"ruleHelp": "仅当新外部账号首次创建且通过现有准入策略时生效。相同邮箱的本地账号不会合并;已有账号请在用户管理中授权。 当前飞书和钉钉不提供已验证邮箱,暂不能用此规则。",
"provider": "身份来源代码",
"provider": "外部登录来源",
"email": "已验证邮箱",
"role": "平台角色",
"addRule": "添加规则",
@ -1796,6 +1814,7 @@
},
"dialog": {
"confirm": "确认",
"processing": "处理中…",
"cancel": "取消",
"delete": "删除",
"close": "关闭"

View file

@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({
@ -13,30 +13,48 @@ const api = vi.hoisted(() => ({
disableRoleGrant: vi.fn(),
}))
vi.mock('@/api/client', () => ({ systemConfigApi: api }))
vi.mock('@/api/client', () => ({
systemConfigApi: api,
ApiError: class ApiError extends Error {
constructor(message: string, public status: number) { super(message) }
},
}))
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
import { SystemConfigPage } from './system-config'
describe('SystemConfigPage', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Object.values(api).forEach((mock) => mock.mockReset())
})
it('warns before disabling login and saves with the observed version', async () => {
function setup(ruleList: unknown[] = []) {
api.getLocalAuth.mockResolvedValue({ passwordLoginEnabled: true, selfRegistrationEnabled: true, version: 3 })
api.listRoles.mockResolvedValue([{ code: 'SUPER_ADMIN', name: 'Super Admin' }])
api.listRoleGrants.mockResolvedValue([])
api.updateLocalAuth.mockResolvedValue({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
vi.spyOn(window, 'confirm').mockReturnValue(true)
api.listRoles.mockResolvedValue([{ code: 'SUPER_ADMIN', name: 'Super Admin' }, { code: 'USER', name: 'User' }])
api.listRoleGrants.mockResolvedValue(ruleList)
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SystemConfigPage /></QueryClientProvider>)
return client
}
it('stages changes, allows discard, and confirms lockout before saving the observed version', async () => {
api.updateLocalAuth.mockResolvedValue({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
const client = setup()
const checkbox = await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' })
fireEvent.click(checkbox)
expect(window.confirm).toHaveBeenCalledWith('systemConfig.lockoutWarning')
expect(api.updateLocalAuth).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.discard' }))
expect((checkbox as HTMLInputElement).checked).toBe(true)
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
const dialog = await screen.findByRole('dialog', { name: 'systemConfig.lockoutTitle' })
expect(api.updateLocalAuth).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.cancel' }))
expect((checkbox as HTMLInputElement).checked).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
await waitFor(() => expect(api.updateLocalAuth.mock.calls[0]?.[0]).toEqual({
passwordLoginEnabled: false,
selfRegistrationEnabled: true,
@ -48,4 +66,69 @@ describe('SystemConfigPage', () => {
registrationAvailable: false,
}))
})
it('keeps the confirmation open when saving fails so the administrator can retry', async () => {
api.updateLocalAuth.mockRejectedValueOnce(new Error('Version conflict'))
.mockResolvedValueOnce({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
setup()
const checkbox = await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' })
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
expect((await within(screen.getByRole('dialog')).findByRole('alert')).textContent).toBe('Version conflict')
expect(screen.getByRole('dialog')).not.toBeNull()
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
await waitFor(() => expect(api.updateLocalAuth).toHaveBeenCalledTimes(2))
})
it('saves a registration change only after Save, without a lockout confirmation', async () => {
api.updateLocalAuth.mockResolvedValue({ passwordLoginEnabled: true, selfRegistrationEnabled: false, version: 4 })
setup()
fireEvent.click(await screen.findByRole('checkbox', { name: 'systemConfig.selfRegistration' }))
expect(api.updateLocalAuth).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
await waitFor(() => expect(api.updateLocalAuth.mock.calls[0]?.[0]).toEqual({
passwordLoginEnabled: true, selfRegistrationEnabled: false, version: 3,
}))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('locks the confirmation while a change is being saved', async () => {
let resolveSave!: (value: unknown) => void
api.updateLocalAuth.mockImplementation(() => new Promise((resolve) => { resolveSave = resolve }))
setup()
fireEvent.click(await screen.findByRole('checkbox', { name: 'systemConfig.passwordLogin' }))
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.saveChanges' }))
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'systemConfig.confirmDisableLogin' }))
const processing = within(dialog).getByRole('button', { name: 'dialog.processing' })
expect((processing as HTMLButtonElement).disabled).toBe(true)
expect((within(dialog).getByRole('button', { name: 'dialog.cancel' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(processing)
await waitFor(() => expect(api.updateLocalAuth).toHaveBeenCalledTimes(1))
resolveSave({ passwordLoginEnabled: false, selfRegistrationEnabled: true, version: 4 })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
it('requires an explicit save after changing an active rule role', async () => {
setup([{ id: 1, providerCode: 'github', email: 'admin@example.com', roleCode: 'SUPER_ADMIN', status: 'ACTIVE', version: 2 }])
await screen.findByText('admin@example.com')
fireEvent.change(screen.getAllByRole('combobox', { name: 'systemConfig.role' })[1], { target: { value: 'USER' } })
expect(api.updateRoleGrant).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByRole('button', { name: 'systemConfig.saveChanges' })[1])
await waitFor(() => expect(api.updateRoleGrant).toHaveBeenCalledWith(1, { version: 2, roleCode: 'USER' }))
})
it('requires an in-page confirmation before disabling a rule', async () => {
api.disableRoleGrant.mockResolvedValue({})
setup([{ id: 1, providerCode: 'github', email: 'admin@example.com', roleCode: 'SUPER_ADMIN', status: 'ACTIVE', version: 2 }])
await screen.findByText('admin@example.com')
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.disable' }))
const dialog = await screen.findByRole('dialog', { name: 'systemConfig.disableTitle' })
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.cancel' }))
expect(api.disableRoleGrant).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'systemConfig.disable' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'systemConfig.disable' }))
await waitFor(() => expect(api.disableRoleGrant).toHaveBeenCalledWith(1, 2))
})
})

View file

@ -1,11 +1,13 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { systemConfigApi } from '@/api/client'
import { ApiError, systemConfigApi } from '@/api/client'
import type { ExternalRoleGrantRule } from '@/api/types'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
export function SystemConfigPage() {
const { t } = useTranslation()
@ -18,6 +20,18 @@ export function SystemConfigPage() {
const [roleCode, setRoleCode] = useState('SUPER_ADMIN')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [draft, setDraft] = useState<{ passwordLoginEnabled: boolean; selfRegistrationEnabled: boolean } | null>(null)
const [confirmLoginOff, setConfirmLoginOff] = useState(false)
const [ruleToDisable, setRuleToDisable] = useState<ExternalRoleGrantRule | null>(null)
const [ruleDrafts, setRuleDrafts] = useState<Record<number, string>>({})
const initializedVersion = useRef<number | null>(null)
useEffect(() => {
if (settings.data && initializedVersion.current !== settings.data.version) {
initializedVersion.current = settings.data.version
setDraft({ passwordLoginEnabled: settings.data.passwordLoginEnabled, selfRegistrationEnabled: settings.data.selfRegistrationEnabled })
}
}, [settings.data])
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ['system-config'] })
@ -44,113 +58,191 @@ export function SystemConfigPage() {
onSuccess: refresh,
})
async function run(action: () => Promise<unknown>) {
async function run(action: () => Promise<unknown>): Promise<boolean> {
setError('')
setMessage('')
try {
await action()
setMessage(t('systemConfig.saved'))
return true
} catch (cause) {
if (cause instanceof ApiError && cause.status === 409) {
setConfirmLoginOff(false)
setRuleToDisable(null)
await refresh()
setError(t('systemConfig.changedElsewhere'))
return false
}
setError(cause instanceof Error ? cause.message : t('systemConfig.saveFailed'))
return false
}
}
function changeSetting(field: 'passwordLoginEnabled' | 'selfRegistrationEnabled', checked: boolean) {
if (!settings.data) return
if (field === 'passwordLoginEnabled' && !checked && !window.confirm(t('systemConfig.lockoutWarning'))) return
void run(() => updateSettings.mutateAsync({
passwordLoginEnabled: field === 'passwordLoginEnabled' ? checked : settings.data!.passwordLoginEnabled,
selfRegistrationEnabled: field === 'selfRegistrationEnabled' ? checked : settings.data!.selfRegistrationEnabled,
version: settings.data!.version,
}))
async function saveSettings() {
if (!settings.data || !draft || updateSettings.isPending) return false
return run(() => updateSettings.mutateAsync({ ...draft, version: settings.data!.version }))
}
function changeRole(rule: ExternalRoleGrantRule, code: string) {
if (code === rule.roleCode) return
void run(() => updateRule.mutateAsync({ id: rule.id, version: rule.version, code }))
const settingsDirty = !!settings.data && !!draft && (
settings.data.passwordLoginEnabled !== draft.passwordLoginEnabled
|| settings.data.selfRegistrationEnabled !== draft.selfRegistrationEnabled
)
function changeRole(rule: ExternalRoleGrantRule) {
const code = ruleDrafts[rule.id]
if (!code || code === rule.roleCode) return
void run(async () => {
await updateRule.mutateAsync({ id: rule.id, version: rule.version, code })
setRuleDrafts((current) => ({ ...current, [rule.id]: code }))
})
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-4 sm:p-8">
<h1 className="text-2xl font-semibold">{t('systemConfig.title')}</h1>
{error ? <p role="alert" className="text-sm text-red-600">{error}</p> : null}
{message ? <p role="status" className="text-sm text-emerald-700">{message}</p> : null}
<div className="mx-auto max-w-5xl space-y-6 p-4 pb-12 sm:p-8">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">{t('systemConfig.title')}</h1>
<p className="text-sm text-muted-foreground">{t('systemConfig.intro')}</p>
</div>
{error ? <p role="alert" className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</p> : null}
{message ? <p role="status" className="rounded-lg bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700">{message}</p> : null}
<Card>
<CardHeader><CardTitle>{t('systemConfig.localAuth')}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<CardHeader>
<CardTitle>{t('systemConfig.localAuth')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('systemConfig.lockoutHelp')}</p>
</CardHeader>
<CardContent className="space-y-4">
{settings.isError ? <p role="alert">{t('systemConfig.loadFailed')}</p> : null}
{settings.data ? (
{settings.isPending ? <p className="text-sm text-muted-foreground">{t('systemConfig.loading')}</p> : null}
{settings.data && draft ? (
<>
<label className="flex items-center gap-3">
<input type="checkbox" checked={settings.data.passwordLoginEnabled} disabled={updateSettings.isPending}
onChange={(event) => changeSetting('passwordLoginEnabled', event.target.checked)} />
{t('systemConfig.passwordLogin')}
</label>
<label className="flex items-center gap-3">
<input type="checkbox" checked={settings.data.selfRegistrationEnabled} disabled={updateSettings.isPending}
onChange={(event) => changeSetting('selfRegistrationEnabled', event.target.checked)} />
{t('systemConfig.selfRegistration')}
</label>
{!settings.data.passwordLoginEnabled && settings.data.selfRegistrationEnabled ? (
<p className="text-sm text-muted-foreground">{t('systemConfig.registrationRequiresLogin')}</p>
<div className="divide-y rounded-xl border border-border/60">
{([
['passwordLoginEnabled', 'passwordLogin', 'passwordLoginHelp'],
['selfRegistrationEnabled', 'selfRegistration', 'selfRegistrationHelp'],
] as const).map(([field, title, help]) => (
<label key={field} className="flex cursor-pointer items-center justify-between gap-6 p-4 sm:p-5">
<span className="min-w-0 space-y-1">
<span className="block text-sm font-medium">{t(`systemConfig.${title}`)}</span>
<span className="block text-sm text-muted-foreground">{t(`systemConfig.${help}`)}</span>
</span>
<span className="relative shrink-0">
<input type="checkbox" className="peer sr-only" checked={draft[field]} disabled={updateSettings.isPending}
onChange={(event) => setDraft({ ...draft, [field]: event.target.checked })}
aria-label={t(`systemConfig.${title}`)} />
<span aria-hidden="true" className="block h-6 w-11 rounded-full bg-muted-foreground/35 transition-colors peer-checked:bg-primary peer-focus-visible:ring-4 peer-focus-visible:ring-ring/30 peer-disabled:opacity-50" />
<span aria-hidden="true" className="pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-5" />
</span>
</label>
))}
</div>
{!draft.passwordLoginEnabled && draft.selfRegistrationEnabled ? (
<p className="rounded-lg bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">{t('systemConfig.registrationRequiresLogin')}</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-3 border-t pt-4">
{settingsDirty ? <span role="status" className="mr-auto text-sm text-muted-foreground">{t('systemConfig.unsaved')}</span> : null}
<Button type="button" variant="outline" disabled={!settingsDirty || updateSettings.isPending}
onClick={() => setDraft({ passwordLoginEnabled: settings.data!.passwordLoginEnabled, selfRegistrationEnabled: settings.data!.selfRegistrationEnabled })}>
{t('systemConfig.discard')}
</Button>
<Button type="button" disabled={!settingsDirty || updateSettings.isPending} onClick={() => {
if (settings.data!.passwordLoginEnabled && !draft.passwordLoginEnabled) setConfirmLoginOff(true)
else void saveSettings()
}}>{updateSettings.isPending ? t('systemConfig.saving') : t('systemConfig.saveChanges')}</Button>
</div>
</>
) : null}
</CardContent>
</Card>
<ConfirmDialog open={confirmLoginOff} onOpenChange={setConfirmLoginOff}
closeOnConfirm={false}
title={t('systemConfig.lockoutTitle')} description={<>{t('systemConfig.lockoutWarning')}{error ? <span role="alert" className="mt-3 block text-destructive">{error}</span> : null}</>}
confirmText={t('systemConfig.confirmDisableLogin')} variant="destructive"
onConfirm={async () => { if (await saveSettings()) setConfirmLoginOff(false) }} />
<Card>
<CardHeader><CardTitle>{t('systemConfig.initialRoleRules')}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>{t('systemConfig.initialRoleRules')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('systemConfig.ruleIntro')}</p>
</CardHeader>
<CardContent className="space-y-5">
<p className="text-sm text-muted-foreground">{t('systemConfig.ruleHelp')}</p>
<form className="grid gap-3 sm:grid-cols-4" onSubmit={(event) => {
<ul className="list-disc space-y-1 rounded-lg bg-muted/60 px-8 py-3 text-sm leading-relaxed text-muted-foreground">
<li>{t('systemConfig.ruleApplies')}</li>
<li>{t('systemConfig.ruleNoMerge')}</li>
<li>{t('systemConfig.ruleUnavailable')}</li>
</ul>
<form className="grid items-end gap-4 rounded-xl border border-border/60 bg-muted/20 p-4 sm:grid-cols-2 lg:grid-cols-[1fr_1.2fr_1fr_auto]" onSubmit={(event) => {
event.preventDefault()
void run(async () => {
await createRule.mutateAsync({ providerCode, email, roleCode })
await createRule.mutateAsync({ providerCode: providerCode.trim(), email: email.trim(), roleCode })
setProviderCode('')
setEmail('')
})
}}>
<Input aria-label={t('systemConfig.provider')} placeholder={t('systemConfig.provider')} value={providerCode}
onChange={(event) => setProviderCode(event.target.value)} required />
<Input aria-label={t('systemConfig.email')} placeholder={t('systemConfig.email')} type="email" value={email}
onChange={(event) => setEmail(event.target.value)} required />
<select aria-label={t('systemConfig.role')} className="rounded-md border bg-background px-3" value={roleCode}
<div className="space-y-1.5"><Label htmlFor="role-grant-provider">{t('systemConfig.provider')}</Label>
<Input id="role-grant-provider" placeholder={t('systemConfig.providerExample')} value={providerCode}
onChange={(event) => setProviderCode(event.target.value)} required maxLength={64} /></div>
<div className="space-y-1.5"><Label htmlFor="role-grant-email">{t('systemConfig.email')}</Label>
<Input id="role-grant-email" placeholder="admin@example.com" type="email" value={email}
onChange={(event) => setEmail(event.target.value)} required /></div>
<div className="space-y-1.5"><Label htmlFor="role-grant-role">{t('systemConfig.role')}</Label>
<select id="role-grant-role" className="h-9 w-full rounded-md border border-border/60 bg-background px-3 text-sm" value={roleCode}
onChange={(event) => setRoleCode(event.target.value)}>
{(roles.data ?? []).map((role) => <option key={role.code} value={role.code}>{role.name}</option>)}
</select>
<Button type="submit" disabled={createRule.isPending || !roles.data?.length}>{t('systemConfig.addRule')}</Button>
</select></div>
<Button type="submit" disabled={createRule.isPending || !roles.data?.length}>{createRule.isPending ? t('systemConfig.saving') : t('systemConfig.addRule')}</Button>
</form>
{rules.isError || roles.isError ? <p role="alert">{t('systemConfig.loadFailed')}</p> : null}
<h3 className="text-sm font-semibold">{t('systemConfig.existingRules')}</h3>
{rules.isPending ? <p className="text-sm text-muted-foreground">{t('systemConfig.loading')}</p> : null}
{rules.data?.length === 0 ? <p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">{t('systemConfig.noRules')}</p> : null}
<div className="space-y-3">
{(rules.data ?? []).map((rule) => (
<div key={rule.id} className="flex flex-wrap items-center gap-3 rounded-md border p-3">
<span className="min-w-0 flex-1 break-all text-sm">{rule.providerCode} · {rule.email}</span>
<span className="text-xs text-muted-foreground">{t(`systemConfig.status.${rule.status}`)}</span>
<div key={rule.id} className="space-y-3 rounded-xl border border-border/60 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-all text-sm font-medium">{rule.email}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('systemConfig.provider')}: {rule.providerCode}</p>
</div>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-secondary-foreground">{t(`systemConfig.status.${rule.status}`)}</span>
</div>
{rule.status === 'CONSUMED' ? (
<span className="w-full break-all text-xs text-muted-foreground">
<p className="break-all text-xs text-muted-foreground">
{t('systemConfig.grantedTo', { userId: rule.grantedUserId, subject: rule.matchedSubject })}
</span>
</p>
) : null}
{rule.status === 'ACTIVE' ? (
<>
<select aria-label={t('systemConfig.role')} className="rounded-md border bg-background px-2 py-1"
value={rule.roleCode} disabled={updateRule.isPending}
onChange={(event) => changeRole(rule, event.target.value)}>
<div className="flex flex-wrap items-end gap-2 border-t pt-3">
<div className="min-w-40 flex-1 space-y-1.5 sm:flex-none">
<Label htmlFor={`rule-role-${rule.id}`}>{t('systemConfig.role')}</Label>
<select id={`rule-role-${rule.id}`} className="h-9 w-full rounded-md border border-border/60 bg-background px-3 text-sm"
value={ruleDrafts[rule.id] ?? rule.roleCode} disabled={updateRule.isPending}
onChange={(event) => setRuleDrafts({ ...ruleDrafts, [rule.id]: event.target.value })}>
{(roles.data ?? []).map((role) => <option key={role.code} value={role.code}>{role.name}</option>)}
</select>
<Button type="button" variant="outline" disabled={disableRule.isPending} onClick={() => {
if (window.confirm(t('systemConfig.disableConfirm'))) {
void run(() => disableRule.mutateAsync({ id: rule.id, version: rule.version }))
}
}}>{t('systemConfig.disable')}</Button>
</>
) : <span className="text-sm">{rule.roleCode}</span>}
</div>
{ruleDrafts[rule.id] && ruleDrafts[rule.id] !== rule.roleCode ? (
<>
<Button type="button" size="sm" variant="outline" disabled={updateRule.isPending} onClick={() => setRuleDrafts({ ...ruleDrafts, [rule.id]: rule.roleCode })}>{t('systemConfig.discard')}</Button>
<Button type="button" size="sm" disabled={updateRule.isPending} onClick={() => changeRole(rule)}>{t('systemConfig.saveChanges')}</Button>
</>
) : null}
<Button type="button" size="sm" variant="ghost" className="sm:ml-auto" disabled={disableRule.isPending}
onClick={() => setRuleToDisable(rule)}>{t('systemConfig.disable')}</Button>
</div>
) : <p className="text-xs text-muted-foreground">{t('systemConfig.role')}: {roles.data?.find((role) => role.code === rule.roleCode)?.name ?? rule.roleCode}</p>}
</div>
))}
</div>
</CardContent>
</Card>
<ConfirmDialog open={!!ruleToDisable} onOpenChange={(open) => { if (!open) setRuleToDisable(null) }}
closeOnConfirm={false}
title={t('systemConfig.disableTitle')} description={<>{t('systemConfig.disableConfirm')}{error ? <span role="alert" className="mt-3 block text-destructive">{error}</span> : null}</>}
confirmText={t('systemConfig.disable')} variant="destructive"
onConfirm={async () => {
if (ruleToDisable && await run(() => disableRule.mutateAsync({ id: ruleToDisable.id, version: ruleToDisable.version }))) {
setRuleToDisable(null)
}
}} />
</div>
)
}

View file

@ -1,4 +1,4 @@
import { ReactNode, useRef } from 'react'
import { ReactNode, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
@ -21,6 +21,7 @@ interface ConfirmDialogProps {
onConfirm: () => void | Promise<void>
contentTestId?: string
confirmButtonTestId?: string
closeOnConfirm?: boolean
}
export function ConfirmDialog({
@ -34,34 +35,43 @@ export function ConfirmDialog({
onConfirm,
contentTestId,
confirmButtonTestId,
closeOnConfirm = true,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const openRef = useRef(open)
const pendingRef = useRef(false)
const [pending, setPending] = useState(false)
openRef.current = open
const resolvedConfirmText = confirmText ?? t('dialog.confirm')
const resolvedCancelText = cancelText ?? t('dialog.cancel')
const handleConfirm = async () => {
await onConfirm()
// Skip close if the caller already closed (e.g. publish success + navigate).
if (openRef.current) {
onOpenChange(false)
if (pendingRef.current) return
pendingRef.current = true
setPending(true)
try {
await onConfirm()
// Skip close if the caller already closed (e.g. publish success + navigate).
if (closeOnConfirm && openRef.current) onOpenChange(false)
} finally {
pendingRef.current = false
setPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid={contentTestId} aria-label={title}>
<Dialog open={open} onOpenChange={(next) => { if (!pendingRef.current) onOpenChange(next) }}>
<DialogContent data-testid={contentTestId} aria-label={title} hideClose={pending}>
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
</DialogHeader>
<DialogFooter className="sm:justify-center sm:space-x-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>
{resolvedCancelText}
</Button>
<Button data-testid={confirmButtonTestId} variant={variant} onClick={handleConfirm}>
{resolvedConfirmText}
<Button data-testid={confirmButtonTestId} variant={variant} disabled={pending} onClick={handleConfirm}>
{pending ? t('dialog.processing') : resolvedConfirmText}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -28,8 +28,8 @@ DialogOverlay.displayName = 'DialogOverlay'
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
>(({ className, children, hideClose = false, ...props }, ref) => (
<DialogPortal container={getPortalContainer()}>
<DialogOverlay />
<DialogPrimitive.Content
@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1.5 text-muted-foreground/60 transition-all duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none">
{!hideClose ? <DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1.5 text-muted-foreground/60 transition-all duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none">
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -59,7 +59,7 @@ const DialogContent = React.forwardRef<
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Close> : null}
</DialogPrimitive.Content>
</DialogPortal>
))