diff --git a/openspec/changes/configure-auth-and-initial-roles/design.md b/openspec/changes/configure-auth-and-initial-roles/design.md index ceb9a2d7..3e819ffc 100644 --- a/openspec/changes/configure-auth-and-initial-roles/design.md +++ b/openspec/changes/configure-auth-and-initial-roles/design.md @@ -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`。审计记录操作者、旧值、新值和时间;不得把秘密配置写入设置或审计详情。 认证路径直接读取数据库中已持久化的安全开关。首版不使用单节点内存缓存,以免多副本部署时设置失效不一致;读取失败应返回服务错误,不能以默认“开启”放行。前端只读取可公开的认证能力投影,不暴露通用设置读写接口。 diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ad920e45..a863e68d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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" diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index e432857e..5f7d8503 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Добавить правило", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 24afa0f0..ba4d66fa 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "关闭" diff --git a/web/src/pages/admin/system-config.test.tsx b/web/src/pages/admin/system-config.test.tsx index d5c0522c..ab3db4c0 100644 --- a/web/src/pages/admin/system-config.test.tsx +++ b/web/src/pages/admin/system-config.test.tsx @@ -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() + 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)) + }) }) diff --git a/web/src/pages/admin/system-config.tsx b/web/src/pages/admin/system-config.tsx index 7759eb28..b754387b 100644 --- a/web/src/pages/admin/system-config.tsx +++ b/web/src/pages/admin/system-config.tsx @@ -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(null) + const [ruleDrafts, setRuleDrafts] = useState>({}) + const initializedVersion = useRef(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) { + async function run(action: () => Promise): Promise { 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 ( -
-

{t('systemConfig.title')}

- {error ?

{error}

: null} - {message ?

{message}

: null} +
+
+

{t('systemConfig.title')}

+

{t('systemConfig.intro')}

+
+ {error ?

{error}

: null} + {message ?

{message}

: null} - {t('systemConfig.localAuth')} - + + {t('systemConfig.localAuth')}

{t('systemConfig.lockoutHelp')}

+
+ {settings.isError ?

{t('systemConfig.loadFailed')}

: null} - {settings.data ? ( + {settings.isPending ?

{t('systemConfig.loading')}

: null} + {settings.data && draft ? ( <> - - - {!settings.data.passwordLoginEnabled && settings.data.selfRegistrationEnabled ? ( -

{t('systemConfig.registrationRequiresLogin')}

+
+ {([ + ['passwordLoginEnabled', 'passwordLogin', 'passwordLoginHelp'], + ['selfRegistrationEnabled', 'selfRegistration', 'selfRegistrationHelp'], + ] as const).map(([field, title, help]) => ( + + ))} +
+ {!draft.passwordLoginEnabled && draft.selfRegistrationEnabled ? ( +

{t('systemConfig.registrationRequiresLogin')}

) : null} +
+ {settingsDirty ? {t('systemConfig.unsaved')} : null} + + +
) : null}
+ {t('systemConfig.lockoutWarning')}{error ? {error} : null}} + confirmText={t('systemConfig.confirmDisableLogin')} variant="destructive" + onConfirm={async () => { if (await saveSettings()) setConfirmLoginOff(false) }} /> - {t('systemConfig.initialRoleRules')} + + {t('systemConfig.initialRoleRules')} +

{t('systemConfig.ruleIntro')}

+
-

{t('systemConfig.ruleHelp')}

-
{ +
    +
  • {t('systemConfig.ruleApplies')}
  • +
  • {t('systemConfig.ruleNoMerge')}
  • +
  • {t('systemConfig.ruleUnavailable')}
  • +
+ { event.preventDefault() void run(async () => { - await createRule.mutateAsync({ providerCode, email, roleCode }) + await createRule.mutateAsync({ providerCode: providerCode.trim(), email: email.trim(), roleCode }) setProviderCode('') setEmail('') }) }}> - setProviderCode(event.target.value)} required /> - setEmail(event.target.value)} required /> - setProviderCode(event.target.value)} required maxLength={64} />
+
+ setEmail(event.target.value)} required />
+
+ - +
+ {rules.isError || roles.isError ?

{t('systemConfig.loadFailed')}

: null} +

{t('systemConfig.existingRules')}

+ {rules.isPending ?

{t('systemConfig.loading')}

: null} + {rules.data?.length === 0 ?

{t('systemConfig.noRules')}

: null}
{(rules.data ?? []).map((rule) => ( -
- {rule.providerCode} · {rule.email} - {t(`systemConfig.status.${rule.status}`)} +
+
+
+

{rule.email}

+

{t('systemConfig.provider')}: {rule.providerCode}

+
+ {t(`systemConfig.status.${rule.status}`)} +
{rule.status === 'CONSUMED' ? ( - +

{t('systemConfig.grantedTo', { userId: rule.grantedUserId, subject: rule.matchedSubject })} - +

) : null} {rule.status === 'ACTIVE' ? ( - <> - setRuleDrafts({ ...ruleDrafts, [rule.id]: event.target.value })}> {(roles.data ?? []).map((role) => )} - - - ) : {rule.roleCode}} +
+ {ruleDrafts[rule.id] && ruleDrafts[rule.id] !== rule.roleCode ? ( + <> + + + + ) : null} + +
+ ) :

{t('systemConfig.role')}: {roles.data?.find((role) => role.code === rule.roleCode)?.name ?? rule.roleCode}

}
))}
+ { if (!open) setRuleToDisable(null) }} + closeOnConfirm={false} + title={t('systemConfig.disableTitle')} description={<>{t('systemConfig.disableConfirm')}{error ? {error} : null}} + confirmText={t('systemConfig.disable')} variant="destructive" + onConfirm={async () => { + if (ruleToDisable && await run(() => disableRule.mutateAsync({ id: ruleToDisable.id, version: ruleToDisable.version }))) { + setRuleToDisable(null) + } + }} /> ) } diff --git a/web/src/shared/components/confirm-dialog.tsx b/web/src/shared/components/confirm-dialog.tsx index 90e757d3..cde4454f 100644 --- a/web/src/shared/components/confirm-dialog.tsx +++ b/web/src/shared/components/confirm-dialog.tsx @@ -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 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 ( - - + { if (!pendingRef.current) onOpenChange(next) }}> + {title} {description && {description}} - - diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index fc53d62a..92ca4ee1 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -28,8 +28,8 @@ DialogOverlay.displayName = 'DialogOverlay' const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { hideClose?: boolean } +>(({ className, children, hideClose = false, ...props }, ref) => ( {children} - + {!hideClose ? Close - + : null} ))