mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-11 03:37:57 +00:00
feat(auth): improve system settings interactions
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
f630519625
commit
44a311ea80
8 changed files with 337 additions and 93 deletions
|
|
@ -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`。审计记录操作者、旧值、新值和时间;不得把秘密配置写入设置或审计详情。
|
||||
|
||||
认证路径直接读取数据库中已持久化的安全开关。首版不使用单节点内存缓存,以免多副本部署时设置失效不一致;读取失败应返回服务错误,不能以默认“开启”放行。前端只读取可公开的认证能力投影,不暴露通用设置读写接口。
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "Добавить правило",
|
||||
|
|
|
|||
|
|
@ -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": "关闭"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue