Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f96fd921 | ||
|
|
f96a591123 | ||
|
|
698fffd929 | ||
|
|
44b964682c | ||
|
|
0655bc37e1 | ||
|
|
9fc7da2ace |
@@ -1,200 +0,0 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.ADMIN_NOTES_REPAIR_BROWSER, 'Run against the task-owned UI preview')
|
||||
|
||||
// Fixed EasyList cosmetic rules that hid the real management controls on 8788.
|
||||
// https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_hide.txt
|
||||
const cosmeticRules = '.ad-body,.ad-hint,.ad-notice,.ad-row,.ad-table { display:none!important }'
|
||||
const stamp = '2026-10-13T02:00:00Z'
|
||||
const commands = ['rdm.role.create', 'rdm.role.action', 'rdm.role.assign', 'rdm.role.disable', 'rdm.role.list',
|
||||
'rdm.inspect.capabilities', 'rdm.inspect.summary', 'rdm.inspect.tree', 'rdm.inspect.object',
|
||||
'rdm.inspect.relations', 'rdm.inspect.events', 'rdm.inspect.history']
|
||||
const roles = [
|
||||
{ roleId: 'engineer', name: '工程师', disabled: false, version: 1, actions: { 'project.create': [true, true], 'doc.write': [false, true] }, accounts: [] },
|
||||
{ roleId: 'reviewer', name: '审核员', disabled: false, version: 1, actions: { 'project.create': [false, false] }, accounts: [] },
|
||||
]
|
||||
const shell = (items: unknown[], nextCursor = '') => ({ items, hasMore: !!nextCursor, nextCursor, truncated: false, coverage: [] })
|
||||
|
||||
async function fixture(page: Page) {
|
||||
let detailFailed = false
|
||||
let childFailed = false
|
||||
let releaseSlow!: () => void
|
||||
let reportSlow!: () => void
|
||||
const slow = new Promise<void>(resolve => { releaseSlow = resolve })
|
||||
const slowStarted = new Promise<void>(resolve => { reportSlow = resolve })
|
||||
page.on('close', releaseSlow)
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'repair-admin', name: '修复验证', auth: true, authVia: 'session', isAdmin: true, mustChangePassword: false }
|
||||
else if (path === '/api/settings') body = {}
|
||||
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||
else if (path === '/api/admin-zone/sections') body = [{ id: 'rdm.audit', title: '项目追溯与审计', icon: 'search' }]
|
||||
else if (path === '/api/admin/users') body = [{ owner: 'repair-admin', name: '修复验证', isAdmin: true, mustChangePassword: false, hasPassword: true, createdAt: stamp }]
|
||||
else if (path.startsWith('/api/commands/')) {
|
||||
const command = decodeURIComponent(path.slice('/api/commands/'.length))
|
||||
const params = request.postDataJSON()?.params ?? {}
|
||||
let payload: unknown
|
||||
if (command === 'rdm.role.list') payload = { roles, count: 2 }
|
||||
else if (command === 'rdm.role.action') payload = params
|
||||
else if (command === 'rdm.inspect.capabilities') payload = { actor: 'repair-admin', crossProject: true, projects: [
|
||||
{ projectId: 'p1', name: '主项目', canAudit: true }, { projectId: 'p2', name: '隔离项目', canAudit: true },
|
||||
] }
|
||||
else if (command === 'rdm.inspect.summary') payload = { projectId: params.projectId, counts: {}, watermark: 10, coverage: [] }
|
||||
else if (command === 'rdm.inspect.tree') {
|
||||
if (params.projectId === 'p2') payload = params.kind === 'notes'
|
||||
? shell([{ kind: 'spine', id: 'other', title: '隔离骨架' }])
|
||||
: shell([{ kind: 'doc', id: 'other-doc', title: '隔离文档' }])
|
||||
else if (params.kind === 'docs') payload = shell(params.parent ? [{ kind: 'section', id: 'sec1', title: '章节一' }] : [{ kind: 'doc', id: 'doc1', title: '项目文档' }])
|
||||
else if (!params.parent) payload = shell([
|
||||
{ kind: 'spine', id: 'root', title: '阶段骨架' },
|
||||
{ kind: 'slice', id: 'slow', title: '慢速资料' }, { kind: 'slice', id: 'fast', title: '快速资料' },
|
||||
])
|
||||
else if (params.parent === 'root') payload = params.cursor ? shell([{ kind: 'slice', id: 'second', title: '第二页资料' }]) : shell([{ kind: 'spine', id: 'child', title: '需求骨架' }], 'root-page-2')
|
||||
else if (params.parent === 'child') {
|
||||
if (!childFailed) {
|
||||
childFailed = true
|
||||
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary child query failure' } } })
|
||||
}
|
||||
payload = shell([{ kind: 'slice', id: 'meeting', title: '会议资料' }])
|
||||
} else payload = shell([])
|
||||
} else if (command === 'rdm.inspect.object') {
|
||||
if (params.id === 'meeting' && !detailFailed) {
|
||||
detailFailed = true
|
||||
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary detail query failure' } } })
|
||||
}
|
||||
if (params.id === 'slow') { reportSlow(); await slow }
|
||||
const titles: Record<string, string> = { root: '阶段骨架详情', child: '需求骨架详情', meeting: '会议资料详情', slow: '慢速资料详情', fast: '快速资料详情', second: '第二页资料详情', sec1: '章节一详情', other: '隔离骨架详情' }
|
||||
payload = { kind: params.kind, id: params.id, projectId: params.projectId, title: titles[params.id], parentId: params.id === 'child' ? 'root' : '', childCount: 1, sliceCount: 1, relations: [], recentEvents: [], coverage: [] }
|
||||
} else return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: `unexpected command ${command}` } } })
|
||||
body = { receipt: { state: 'committed', commandId: command }, payload }
|
||||
}
|
||||
await route.fulfill({ status: 200, json: body })
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.tsb-user')).toContainText('修复验证')
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByText('系统管理…', { exact: true }).click()
|
||||
return { slowStarted, releaseSlow }
|
||||
}
|
||||
|
||||
async function notes(page: Page) {
|
||||
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.getByRole('button', { name: '笔记树', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('advertising filters leave management forms and role tables visible', async ({ page }, testInfo) => {
|
||||
await fixture(page)
|
||||
await page.addStyleTag({ content: cosmeticRules })
|
||||
await expect(page.getByPlaceholder('用户名(登录名)')).toBeVisible()
|
||||
await page.getByPlaceholder('用户名(登录名)').fill('new-user')
|
||||
await page.getByPlaceholder('初始密码(≥8 位)').fill('fixture-password')
|
||||
await expect(page.getByRole('button', { name: '创建账号', exact: true })).toBeEnabled()
|
||||
await expect(page.getByRole('cell', { name: 'repair-admin', exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('users-with-adblock.png') })
|
||||
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '创建角色', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('cell', { name: /工程师/ })).toBeVisible()
|
||||
await expect(page.getByLabel('默认允许')).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('roles-with-adblock.png') })
|
||||
})
|
||||
|
||||
test('selecting a role and action loads their actual permission checkboxes', async ({ page }) => {
|
||||
await fixture(page)
|
||||
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
|
||||
const selects = page.locator('.az-main select')
|
||||
await selects.nth(0).selectOption('engineer')
|
||||
await expect(page.getByLabel('默认允许')).toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).toBeChecked()
|
||||
const saved = page.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/rdm.role.action'))
|
||||
const refreshed = page.waitForResponse(r => r.url().endsWith('/rdm.role.list'))
|
||||
await page.getByRole('button', { name: '保存动作', exact: true }).click()
|
||||
expect((await saved).postDataJSON()).toEqual({ params: {
|
||||
roleId: 'engineer', action: 'project.create', default: true, cap: true,
|
||||
} })
|
||||
await refreshed
|
||||
await expect(page.getByLabel('默认允许')).toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).toBeChecked()
|
||||
await selects.nth(1).selectOption('doc.write')
|
||||
await expect(page.getByLabel('默认允许')).not.toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).toBeChecked()
|
||||
await selects.nth(1).selectOption('project.create')
|
||||
await selects.nth(0).selectOption('reviewer')
|
||||
await expect(page.getByLabel('默认允许')).not.toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('notes expand nested spines, retry a child query, and inspect mounted slices', async ({ page }, testInfo) => {
|
||||
await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('阶段骨架详情')
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
|
||||
await expect(page.getByRole('button', { name: '重试列表', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '重试列表', exact: true }).click()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' }).click()
|
||||
await expect(page.getByRole('button', { name: '重试详情', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '重试详情', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('会议资料详情')
|
||||
await page.screenshot({ path: testInfo.outputPath('notes-detail.png') })
|
||||
})
|
||||
|
||||
test('each expanded spine can load its next page', async ({ page }) => {
|
||||
await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '加载更多', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '第二页资料' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('late detail responses cannot replace the newly selected object', async ({ page }) => {
|
||||
const control = await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
|
||||
await control.slowStarted
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '快速资料' }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
|
||||
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
|
||||
control.releaseSlow()
|
||||
await response
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
|
||||
})
|
||||
|
||||
test('project switching clears note descendants and in-flight object details', async ({ page }) => {
|
||||
const control = await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
|
||||
await page.getByRole('button', { name: '重试列表', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
|
||||
await control.slowStarted
|
||||
await page.locator('.rdm-audit-project').selectOption('p2')
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '隔离骨架' })).toBeVisible()
|
||||
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
|
||||
control.releaseSlow()
|
||||
await response
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toHaveCount(0)
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toHaveCount(0)
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toHaveCount(0)
|
||||
await expect(page.locator('.rdm-audit-detail')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('document expansion and section inspection remain available', async ({ page }) => {
|
||||
await fixture(page)
|
||||
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '项目文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '章节一' }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('章节一详情')
|
||||
})
|
||||
@@ -17,14 +17,21 @@ async function login(page: Page) {
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
|
||||
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
|
||||
const gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(0).fill(PASSWORD2)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
|
||||
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
|
||||
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
|
||||
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
|
||||
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
|
||||
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
|
||||
|
||||
async function passwordLogin(page: Page, owner: string, password: string) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(owner)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
|
||||
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
|
||||
async function adminLogin(page: Page) {
|
||||
await passwordLogin(page, ADMIN, ADMIN_BOOT)
|
||||
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
|
||||
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
|
||||
let gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!gated) {
|
||||
// boot 密码失败(此前运行已完成改密):换正式密码重登。
|
||||
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
|
||||
if (!loginErr) throw new Error('admin login neither gated nor errored')
|
||||
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||
gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
}
|
||||
if (gated) {
|
||||
const pw = page.locator('.lg-card input[type="password"]')
|
||||
await pw.nth(0).fill(ADMIN_PW)
|
||||
await pw.nth(1).fill(ADMIN_PW)
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
|
||||
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||
}
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
// 每个用户行都有同款按钮:限定目标用户所在行。
|
||||
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
|
||||
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
|
||||
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 })
|
||||
const key = await keyInput.inputValue()
|
||||
expect(key).toMatch(/^alk_/)
|
||||
await expect(zone.getByText(/单次有效/)).toBeVisible()
|
||||
|
||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
await zone.getByRole('button', { name: '返回工作台' }).click()
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
|
||||
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||
|
||||
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
+10
-5
@@ -91,11 +91,14 @@ export function App() {
|
||||
try {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.status === 401) { setAuthState('login'); return }
|
||||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||||
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||||
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
|
||||
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
|
||||
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
|
||||
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
|
||||
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) {
|
||||
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
|
||||
if (gated) {
|
||||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||||
}
|
||||
@@ -173,9 +176,11 @@ export function App() {
|
||||
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||||
}
|
||||
if (authState === 'mustchange') {
|
||||
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
|
||||
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
|
||||
return <ChangePasswordGate
|
||||
owner={mustChangeOwner}
|
||||
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
|
||||
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
|
||||
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
||||
}
|
||||
if (authState !== 'ok') {
|
||||
|
||||
+17
-4
@@ -42,20 +42,29 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }>
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }>
|
||||
}),
|
||||
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
|
||||
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
|
||||
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||
@@ -117,6 +126,10 @@ export const api = {
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
|
||||
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
|
||||
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
|
||||
@@ -9,13 +9,13 @@ import { humanChangePasswordError } from './LoginPage'
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal admin-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
|
||||
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<b>{title}</b>
|
||||
<span className="spacer" />
|
||||
<button className="admin-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
|
||||
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
|
||||
</div>
|
||||
<div className="admin-body">{children}</div>
|
||||
<div className="ad-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -51,8 +51,8 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
return (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="admin-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<form className="admin-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
@@ -31,6 +31,8 @@ const SECTIONS: AdminSectionDef[] = [
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 登录与改密(管理员发放制,R2):
|
||||
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
|
||||
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
|
||||
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
|
||||
import { useState } from 'react'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
@@ -28,9 +28,11 @@ function BrandPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
|
||||
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
|
||||
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||
const [tab, setTab] = useState<'password' | 'apikey'>('password')
|
||||
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
@@ -43,8 +45,9 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
const me = await api.login(owner.trim(), password)
|
||||
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||
auth.beginSession()
|
||||
if (me.mustChangePassword) {
|
||||
// 初始/重置密码:先完成强制改密再放行。
|
||||
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
|
||||
// 受限会话先进设密门,完成前不放行。
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
@@ -56,24 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
|
||||
const submitKey = async () => {
|
||||
setErr(''); setBusy(true)
|
||||
auth.setKey(key)
|
||||
try {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.ok) {
|
||||
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
|
||||
// 不放行进死路改密门,引导改走账号密码登录。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) {
|
||||
auth.clear()
|
||||
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
|
||||
return
|
||||
}
|
||||
onAuthed(); return
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
auth.beginSession()
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
throw new Error(String(r.status))
|
||||
} catch {
|
||||
auth.clear()
|
||||
setErr('密钥无效或已撤销,请重试')
|
||||
onAuthed()
|
||||
} catch (e) {
|
||||
setErr(humanRedeemError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -84,10 +80,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
|
||||
<div className="lg-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
@@ -100,11 +96,11 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="lg-label">API 访问密钥</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
|
||||
<label className="lg-label">一次性登录 Key</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
|
||||
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner <name></code>。程序化客户端也可直接带 Authorization 头。</p>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
|
||||
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
@@ -113,13 +109,15 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
|
||||
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
|
||||
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
|
||||
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setErr('')
|
||||
@@ -127,8 +125,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword(current, next)
|
||||
onDone()
|
||||
await api.changePassword('', next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
@@ -138,20 +136,27 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<div className="lg-wrap">
|
||||
<BrandPanel />
|
||||
<div className="lg-formside">
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">修改密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
|
||||
<label className="lg-label">当前密码(初始/重置密码)</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
|
||||
</form>
|
||||
{done ? (
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">密码已更新</h2>
|
||||
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
|
||||
<button className="lg-submit" type="submit">返回登录</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">设置新密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -165,13 +170,23 @@ export function humanLoginError(raw: string): string {
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; parentId?: string; childCount?: number; sliceCount?: number; mounts?: string[]; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
|
||||
// ---- 工作区主组件 ----
|
||||
@@ -148,107 +148,102 @@ function MaterialsTab({ pid }: { pid: string }) {
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
</div>
|
||||
<TreeList key={`${pid}:${kind}`} pid={pid} kind={kind} />
|
||||
<TreeList pid={pid} kind={kind} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
const [detailError, setDetailError] = useState('')
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => () => { genRef.current++ }, [])
|
||||
|
||||
const selectNode = useCallback((item: TreeItem) => {
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
setSelected(item); setDetail(null); setDetailError('')
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, kind])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
|
||||
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleDoc = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setDetailError(e.message) })
|
||||
}, [pid])
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<TreeLevel pid={pid} kind={kind} parent="" selected={selected} onSelect={selectNode} />
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>对象详情</SectionLabel>
|
||||
{detailError ? <>
|
||||
<RdmErrorState raw={detailError} />
|
||||
<button className="btn v-ghost" onClick={() => selectNode(selected)}>重试详情</button>
|
||||
</> : detail ? <ObjectDetailView detail={detail} /> : <div className="empty-note">加载中…</div>}
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TreeLevelProps { pid: string; kind: 'notes' | 'docs'; parent: string; selected: TreeItem | null; onSelect: (item: TreeItem) => void }
|
||||
|
||||
function TreeLevel({ pid, kind, parent, selected, onSelect }: TreeLevelProps) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const genRef = useRef(0)
|
||||
const cursorRef = useRef('')
|
||||
const load = useCallback((cursor = '') => {
|
||||
const gen = ++genRef.current
|
||||
cursorRef.current = cursor
|
||||
setLoading(true); setError('')
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent, cursor })
|
||||
.then(next => {
|
||||
if (gen !== genRef.current) return
|
||||
setShell(prev => cursor && prev ? { ...next, items: [...prev.items, ...next.items] } : next)
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setError(e.message) })
|
||||
.finally(() => { if (gen === genRef.current) setLoading(false) })
|
||||
}, [pid, kind, parent])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
return () => { genRef.current++ }
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell?.items.map(item => <TreeNode key={`${item.kind}:${item.id}`} item={item}
|
||||
pid={pid} kind={kind} selected={selected} onSelect={onSelect} />)}
|
||||
{!shell && loading && <div className="empty-note">加载中…</div>}
|
||||
{shell?.items.length === 0 && <div className="empty-note">{parent ? kind === 'notes' ? '无子骨架或挂载切片' : '无章节' : kind === 'notes' ? '暂无骨架节点或切片' : '暂无文档'}</div>}
|
||||
{error && <>
|
||||
<RdmErrorState raw={error} />
|
||||
<button className="btn v-ghost" disabled={loading} onClick={() => load(cursorRef.current)}>重试列表</button>
|
||||
</>}
|
||||
{shell?.hasMore && !error && <button className="btn v-ghost rdm-audit-more" disabled={loading}
|
||||
onClick={() => load(shell.nextCursor)}>加载更多</button>}
|
||||
{shell?.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({ item, pid, kind, selected, onSelect }: Omit<TreeLevelProps, 'parent'> & { item: TreeItem }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const branch = item.kind === 'doc' || item.kind === 'spine'
|
||||
const className = `rdm-audit-tree-node${selected?.kind === item.kind && selected.id === item.id ? ' sel' : ''}`
|
||||
const label = <>
|
||||
{branch && <span className="rdm-audit-tree-caret">{open ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</>
|
||||
if (!branch) return <button className={className} onClick={() => onSelect(item)}>{label}</button>
|
||||
return <Collapsible open={open} trigger={label} triggerClassName={className}
|
||||
bodyClassName="rdm-audit-tree-children" onToggle={next => {
|
||||
setOpen(next)
|
||||
if (item.kind === 'spine') onSelect(item)
|
||||
}}>
|
||||
<TreeLevel pid={pid} kind={kind} parent={item.id} selected={selected} onSelect={onSelect} />
|
||||
</Collapsible>
|
||||
}
|
||||
|
||||
// ---- 关联追溯 ----
|
||||
|
||||
function TraceTab({ pid }: { pid: string }) {
|
||||
@@ -475,11 +470,6 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.kind === 'spine' && <>
|
||||
<div>父骨架:{detail.parentId || '根节点'}</div>
|
||||
<div>子骨架 {detail.childCount ?? 0} · 挂载切片 {detail.sliceCount ?? 0}</div>
|
||||
</>}
|
||||
{!!detail.mounts?.length && <div>挂载骨架:{detail.mounts.join('、')}</div>}
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 admin-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useStore } from '../store'
|
||||
@@ -38,11 +38,9 @@ export function RdmRolesPanel() {
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
const sel = roles?.find(role => role.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
@@ -52,11 +50,6 @@ export function RdmRolesPanel() {
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
setEditDef(editCurrent?.[0] ?? false)
|
||||
setEditCap(editCurrent?.[1] ?? false)
|
||||
}, [roles, editRole, editAction])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
@@ -69,75 +62,83 @@ export function RdmRolesPanel() {
|
||||
// rolesListPlaceholder 注释)。
|
||||
const placeholder = rolesListPlaceholder(roles, err)
|
||||
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !newName.trim()}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="admin-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
</div>
|
||||
|
||||
<table className="admin-table">
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td><span className="admin-mono">{r.name}</span><br /><span className="admin-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="admin-chip admin-chip-warn">已停用</span> : <span className="admin-chip">生效</span>}</td>
|
||||
<td className="admin-dim">v{r.version}</td>
|
||||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="admin-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="admin-mini" disabled={busy}
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无专业角色</td></tr>}
|
||||
{placeholder && <tr><td colSpan={6} className="admin-dim">{placeholder}</td></tr>}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||||
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||||
<div className="admin-row">
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction} onChange={e => setEditAction(e.target.value)}>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => {
|
||||
setEditAction(e.target.value)
|
||||
const cur = sel?.actions?.[e.target.value]
|
||||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||||
}}>
|
||||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<label className="admin-check"><input type="checkbox" checked={editDef}
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="admin-check"><input type="checkbox" checked={editCap}
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !editRole}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="admin-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="admin-row">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -171,41 +172,41 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
|
||||
onChange={e => setMember(e.target.value)} />
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">member(成员)</option>
|
||||
<option value="owner">owner(负责人)</option>
|
||||
</select>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !member.trim()}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
|
||||
onClick={() => run(`已添加 ${member.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
|
||||
setMember('')
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="admin-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<table className="admin-table">
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td className="admin-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="admin-chip admin-chip-admin">OWNER</span> : <span className="admin-chip">MEMBER</span>}</td>
|
||||
<td className="admin-dim">{m.addedBy || '—'}</td>
|
||||
<td className="admin-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="admin-mini" disabled={busy}
|
||||
<td className="ad-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="admin-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="admin-dim">加载中…</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -252,8 +253,8 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
|
||||
@@ -264,28 +265,28 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
|
||||
{sub && (
|
||||
<div className="settings-group">
|
||||
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="admin-chip admin-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="admin-chip admin-chip-admin">已终批</span> : <span className="admin-chip">已驳回</span>}</h4>
|
||||
<div className="setting-row"><label>三权</label><span>编制 <b className="admin-mono">{sub.duties.author}</b> · 审核 <b className="admin-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="admin-mono">{sub.duties.approver}</b></span></div>
|
||||
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
|
||||
<div className="setting-row"><label>三权</label><span>编制 <b className="ad-mono">{sub.duties.author}</b> · 审核 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="ad-mono">{sub.duties.approver}</b></span></div>
|
||||
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="admin-mono admin-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="admin-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('整文终批完成(各章节已基线)',
|
||||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||||
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已通过整文审核',
|
||||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||||
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === undefined && docId && <p className="admin-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
|
||||
<div className="admin-row">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
|
||||
@@ -295,18 +296,18 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
|
||||
onChange={e => setApprover(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-row">
|
||||
<button className="lg-submit admin-create-btn"
|
||||
<div className="ad-row">
|
||||
<button className="lg-submit ad-create-btn"
|
||||
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
|
||||
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
|
||||
<button className="admin-mini" disabled={busy || !docId}
|
||||
<button className="ad-mini" disabled={busy || !docId}
|
||||
onClick={() => run('整文已提交审核(全文锁定)',
|
||||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||||
</div>
|
||||
<p className="admin-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -350,39 +351,39 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||
<option value="">选择成员(默认自省)…</option>
|
||||
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||
</select>
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
|
||||
</div>
|
||||
<p className="admin-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
|
||||
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
|
||||
</div>
|
||||
{matrix && (
|
||||
<>
|
||||
<div className="admin-row">
|
||||
<span>成员 <b className="admin-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="admin-table">
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
|
||||
<tbody>
|
||||
{(matrix.actions ?? []).map(a => (
|
||||
<tr key={a.action}>
|
||||
<td className="admin-mono">{a.action}</td>
|
||||
<td>{a.allowed ? <span className="admin-chip admin-chip-admin">允许</span> : <span className="admin-chip admin-chip-warn">拒绝</span>}</td>
|
||||
<td className="admin-dim">{describeSource(a.source)}</td>
|
||||
<td className="admin-dim">{a.reason || '—'}</td>
|
||||
<td className="ad-mono">{a.action}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{!matrix && !err && members !== null && <p className="admin-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,11 +6,22 @@ import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
|
||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
|
||||
|
||||
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
|
||||
function keyStatusChip(status: string) {
|
||||
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
|
||||
if (status === 'consumed') return <span className="ad-chip">已使用</span>
|
||||
if (status === 'expired') return <span className="ad-chip">已过期</span>
|
||||
return <span className="ad-chip">已撤销</span>
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -22,14 +33,24 @@ export function UsersPanel() {
|
||||
const [pw, setPw] = useState('')
|
||||
const [asAdmin, setAsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
const [resetPw, setResetPw] = useState('')
|
||||
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
|
||||
const [keyFor, setKeyFor] = useState<string | null>(null)
|
||||
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
|
||||
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshKeys = async (owner: string) => {
|
||||
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const openKeyPanel = async (owner: string) => {
|
||||
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
|
||||
await refreshKeys(owner)
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
||||
@@ -42,63 +63,97 @@ export function UsersPanel() {
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
if (!resetFor) return
|
||||
setErr('')
|
||||
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
const issue = async () => {
|
||||
if (!keyFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.resetAdminUserPassword(resetFor, resetPw)
|
||||
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
|
||||
setResetFor(null); setResetPw('')
|
||||
await refresh()
|
||||
const out = await api.adminIssueLoginKey(keyFor)
|
||||
setIssued({ key: out.key, expiresAt: out.expiresAt })
|
||||
await refreshKeys(keyFor)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!issued) return
|
||||
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
|
||||
}
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!keyFor) return
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeLoginKey(keyFor, id)
|
||||
await refreshKeys(keyFor)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-row">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="admin-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="admin-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{resetFor ? (
|
||||
<div className="admin-reset">
|
||||
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
||||
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||
<button type="button" className="admin-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||
{keyFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="ad-row">
|
||||
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
|
||||
</div>
|
||||
{issued && (
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
|
||||
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
|
||||
</div>
|
||||
)}
|
||||
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
|
||||
{keys !== null && keys.length > 0 && (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{keys.map(k => (
|
||||
<tr key={k.id}>
|
||||
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
|
||||
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
|
||||
<td>{keyStatusChip(k.status)}</td>
|
||||
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
|
||||
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="admin-user-cell"><span className="admin-mono">{u.owner}</span></td>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="admin-chip admin-chip-admin">ADMIN</span> : <span className="admin-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="admin-chip admin-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="admin-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<button className="admin-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无账号</td></tr>}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
@@ -240,3 +295,74 @@ export function PluginWorkspacesPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
|
||||
|
||||
/**
|
||||
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||||
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||||
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||||
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
|
||||
* 原样传回,由服务端视为未修改。
|
||||
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||||
*/
|
||||
export function SystemSettingsPanel() {
|
||||
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [forbidden, setForbidden] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setErr(''); setForbidden(false)
|
||||
try {
|
||||
const out = await api.getSystemSettings()
|
||||
setSchema(out.schema.filter(d => d.scope === 'system'))
|
||||
setDraft(flatten(out.system))
|
||||
setLoaded(true)
|
||||
} catch (e) {
|
||||
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||||
else setErr(String(e))
|
||||
}
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const groups: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||||
|
||||
const save = async () => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.putSystemSettings(unflatten(draft))
|
||||
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
|
||||
await load()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||||
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="ad-hint" style={{ marginBottom: 14 }}>
|
||||
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
|
||||
</p>
|
||||
{Object.entries(groups).map(([group, descs]) => (
|
||||
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
|
||||
{descs.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
<div className="ad-row" style={{ marginTop: 12 }}>
|
||||
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
|
||||
</div>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,6 +270,8 @@ export interface SettingDesc {
|
||||
default?: unknown; options?: string[];
|
||||
effect: 'hot' | 'new-run' | 'restart';
|
||||
secret?: boolean;
|
||||
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
|
||||
scope?: 'system';
|
||||
}
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
+24
-24
@@ -497,29 +497,29 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
|
||||
|
||||
/* ---- 用户管理 / 改密弹层 ---- */
|
||||
.admin-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||
.admin-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||
.admin-row { display: flex; gap: 8px; align-items: center; }
|
||||
.admin-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||
.admin-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
.admin-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||
.admin-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.admin-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.admin-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.admin-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.admin-notice svg { flex: none; margin-top: 1px; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.admin-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.admin-dim { color: var(--text-dim); }
|
||||
.admin-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.admin-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.admin-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.admin-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.admin-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.admin-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||
.ad-row { display: flex; gap: 8px; align-items: center; }
|
||||
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.ad-notice svg { flex: none; margin-top: 1px; }
|
||||
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
|
||||
/* ---- 收起轨道(左栏收起态) ---- */
|
||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
|
||||
@@ -531,7 +531,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.role-mark.role-assistant { background: none; border: none; padding: 0; }
|
||||
.msg.user .msg-role { align-items: center; }
|
||||
.admin-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.artifact-chip .ac-act { border: none; background: none; }
|
||||
|
||||
|
||||
+18
-1
@@ -1,7 +1,7 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -30,3 +30,20 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(登录恢复新增文案)', () => {
|
||||
it('并发凭据变化给出重新登录指引', () => {
|
||||
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user