Compare commits

...
Author SHA1 Message Date
weichen 551697d67d Merge remote-tracking branch 'origin/main' into fix/role-editor-single-project-toggle
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 1m2s
2026-10-23 10:29:27 +00:00
agent-exec-transport dca48a1d75 Hide system actions from the role editor
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 37s
Why: Owner reported that creating a role showed system admin permissions. The
server rejects protected system-scope actions for role writes anyway; they are
only configurable per account in the users panel, so showing them in the role
editor is misleading.

Evidence: npm run build passes and npx vitest run passes (717 tests); the two
account-panel tests that assert system capability groups keep passing because the
filter lives at the role editor call site, not in buildCapabilityGroups. The
agent-exec transport diff matches the owner-accepted change.

Impact: the role editor builds capability groups without system.* actions; the
per-account explicit grant surface is unchanged.

Refs: fix/role-editor-single-project-toggle
2026-10-23 10:27:02 +00:00
weichen d643145739 Merge pull request 'Fix the e2e first-login gate flow and admin hint locator' (#128) from fix/e2e-gate-flow into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 41s
2026-10-23 10:20:16 +00:00
agent-exec-transport dc062820bc agent-exec transport 6b75522dbe048a8b6a6156edfa6f508e/a250410c65cf
ci / test-and-build (pull_request) Failing after 1s
governance / governance (pull_request) Successful in 16s
2026-10-23 09:56:32 +00:00
agent-exec-transport ded9b568b0 agent-exec transport 4a92e3a9fabe41f2d62ffbceeb92daa1/267104e747c8 2026-10-23 09:48:49 +00:00
agent-exec-transport d91200192a agent-exec transport 0091f9ff5f85f944b825407b437f0281/e0053536f16c 2026-10-23 08:34:58 +00:00
weichen 807c656886 Merge pull request 'Update Gitea endpoints after server migration' (#126) from fix/gitea-endpoint-migration into main
governance / governance (push) Successful in 22s
ci / test-and-build (push) Successful in 1m58s
2026-10-22 15:22:40 +00:00
6 changed files with 132 additions and 33 deletions
+36 -14
View File
@@ -15,22 +15,39 @@ const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
async function passwordLogin(page: Page, password: string) {
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
// 三种落点:直接进工作台(密码已生效)|强制改密门|初始密码已失效(种子二次运行)→ 改用新密码重登。
}
/** 强制改密门:设新密码后服务端销毁会话,回到登录页。 */
async function completePasswordGate(page: Page) {
// 门模式异步加载:current_required 渲染 #forced-current,其余模式渲染受限设密提示。
const current = page.locator('#forced-current')
await expect(current.or(page.locator('.lg-card .lg-hint'))).toBeVisible()
if (await current.isVisible()) await current.fill(PASSWORD)
// 「新密码(≥8 位)」「确认新密码」label 未关联输入框,getByLabel 无法命中,按门内顺序定位。
const pw = page.locator('.lg-card input[type="password"]:not(#forced-current)')
await pw.nth(0).fill(PASSWORD2)
await pw.nth(1).fill(PASSWORD2)
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()
}
async function login(page: Page) {
await page.goto('/')
await passwordLogin(page, PASSWORD)
// 三种落点:直接进工作台(密码已生效)|强制改密门 → 设新密码后重登|初始密码已失效(种子二次运行)→ 改用新密码重登。
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!entered) {
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位。
const gated = await page.getByRole('heading', { name: '设置新密码' }).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(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
await completePasswordGate(page)
await passwordLogin(page, PASSWORD2)
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
@@ -69,13 +86,18 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
// 契约断言三:请求不带密码时服务端生成一次性初始密码(响应 initialPassword,
// 账号 hasPassword=true 且 mustChangePassword=true)——页面一次性交付该密码,
// 用户表该账号状态为待改密而非未设密码,且未被授予管理员。
await expect(page.locator('.ad-reset .ad-hint')).toContainText(`初始密码仅显示一次,请立即安全交付给 ${newOwner}`)
await expect(page.locator('.ad-reset input[aria-label="一次性密码"]')).not.toHaveValue('')
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
await expect(row).toBeVisible()
await expect(row).toContainText('未设密码')
await expect(row).toContainText('待改密')
await expect(row).not.toContainText('未设密码')
await expect(row).not.toContainText('ADMIN')
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
await expect(page.locator('.ad-create .ad-hint').filter({ hasText: '初始密码' })).toContainText('初始密码')
await expect(page.locator('.ad-create .ad-hint').filter({ hasText: 'user initialize-account' })).toHaveCount(0)
})
+28 -10
View File
@@ -16,21 +16,39 @@ const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
async function passwordLogin(page: Page, password: string) {
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
}
/** 强制改密门:设新密码后服务端销毁会话,回到登录页。 */
async function completePasswordGate(page: Page) {
// 门模式异步加载:current_required 渲染 #forced-current,其余模式渲染受限设密提示。
const current = page.locator('#forced-current')
await expect(current.or(page.locator('.lg-card .lg-hint'))).toBeVisible()
if (await current.isVisible()) await current.fill(PASSWORD)
// 「新密码(≥8 位)」「确认新密码」label 未关联输入框,getByLabel 无法命中,按门内顺序定位。
const pw = page.locator('.lg-card input[type="password"]:not(#forced-current)')
await pw.nth(0).fill(PASSWORD2)
await pw.nth(1).fill(PASSWORD2)
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()
}
async function login(page: Page) {
await page.goto('/')
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
await passwordLogin(page, PASSWORD)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位。
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(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
// 设密后会话已销毁:用新密码重登。
await completePasswordGate(page)
await passwordLogin(page, PASSWORD2)
} else {
// 已改密:改用新密码重登。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
+34 -7
View File
@@ -53,6 +53,37 @@ async function openReaderOnDoc(page: Page, title: string) {
await expect(page.locator('.cockpit-reader')).toBeVisible()
}
/**
* 以本人会话落一条已提交的需求账本条目(唯一键防幂等跳过)。正式项目命令必须锚定
* 同会话已提交的来源消息(基座来源门禁,与页面审签 documentDecision 同一路径):
* 宿主对话项目 → 新建探针对话 → 提交来源消息 → 以该消息为锚点分发;回执必须 committed。
*/
async function commitProbeChange(page: Page, rdmProjectId: string) {
const projects = await (await page.request.get('/api/projects')).json() as { id: string; settings?: Record<string, unknown> }[]
const host = projects.find(p => p.settings?.['rdm.projectId'] === rdmProjectId)
expect(host, `宿主对话项目须绑定 ${rdmProjectId}`).toBeTruthy()
const conv = await page.request.post(`/api/projects/${host!.id}/conversations`, { data: { title: '驾驶舱一致性探针' } })
expect(conv.ok(), `探针对话创建:HTTP ${conv.status()}`).toBe(true)
const conversationId = (await conv.json()).id as string
const stamp = Date.now()
const title = `UR-PROBE-${stamp} 一致性探针`
const sent = await page.request.post(`/api/conversations/${conversationId}/messages`, {
data: { text: `一致性探针:向需求账本导入条目「${title}」`, attachmentIds: [], requestId: `cockpit-probe:${stamp}` },
})
expect(sent.ok(), `探针来源消息:HTTP ${sent.status()}`).toBe(true)
const anchorMessageId = (await sent.json()).message?.id as string
expect(anchorMessageId, '来源消息须已提交').toBeTruthy()
const res = await page.request.post('/api/commands/rdm.reqs.import', {
data: {
params: { projectId: rdmProjectId, items: [{ kind: 'UR', title, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] },
conversationId, anchorMessageId, entrance: 'api',
},
failOnStatusCode: false,
})
const body = await res.json().catch(() => null)
expect(body?.receipt?.state, `探针写入回执:HTTP ${res.status()} ${JSON.stringify(body?.receipt ?? null)}`).toBe('committed')
}
test.describe.serial('I4 文档驾驶舱组合验收', () => {
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
@@ -125,13 +156,9 @@ test.describe.serial('I4 文档驾驶舱组合验收', () => {
await branch4.locator('.cockpit-tree-caret').click()
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
const snapBefore = await page.locator('.cockpit-snap').innerText()
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
await page.request.post('/api/commands/rdm.reqs.import', {
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
failOnStatusCode: false,
})
// 通过应用自身 API 落一个已提交变更(采纳走高危确认卡、服务端不允许客户端伪造
// Confirmed,故用低危提交面驱动);探针被拒即在此失败,不让水位断言替它背锅。
await commitProbeChange(page, await page.locator('.cockpit-project').inputValue())
await page.getByRole('button', { name: '刷新' }).click()
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
// 版本块前进:出现新版本条目。
+5 -1
View File
@@ -143,7 +143,11 @@ export function RolePermissionsPanel() {
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const filtered = roles ?? []
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
// 角色只承载项目范围动作:受保护的系统授权由服务端整批拒绝,
// 在角色编辑面展示只会误导(账号级显式授权仍在用户面板单独配置)。
const capabilityGroups = useMemo(
() => buildCapabilityGroups(catalog.filter(item => !item.protected && item.actionId.indexOf('system.') !== 0)),
[catalog])
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
const breaks = draft ? linkedChainBreaks(draft.values) : []
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { UserPasswordStatus } from './adminSections'
const status = (hasPassword: boolean, mustChangePassword: boolean) =>
renderToStaticMarkup(<UserPasswordStatus account={{ hasPassword, mustChangePassword }} />)
describe('UserPasswordStatus', () => {
it('shows an account without a password as unset even when it carries the must-change flag', () => {
expect(status(false, true)).toBe('未设密码')
expect(status(false, false)).toBe('未设密码')
})
it('shows a password that must be changed, such as a server-generated initial password, as pending change', () => {
expect(status(true, true)).toContain('待改密')
expect(status(true, true)).toContain('ad-chip-warn')
})
it('shows an account with a settled password as normal', () => {
expect(status(true, false)).toBe('正常')
})
})
+7 -1
View File
@@ -17,6 +17,12 @@ import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCred
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
// 无密码账号的改密旗标没有可改的密码,不得冒充「待改密」。
export function UserPasswordStatus({ account }: { account: Pick<AdminUserRow, 'hasPassword' | 'mustChangePassword'> }) {
if (!account.hasPassword) return <>未设密码</>
return account.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : <>正常</>
}
export function UsersPanel() {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
@@ -83,7 +89,7 @@ export function UsersPanel() {
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : 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><UserPasswordStatus account={u} /></td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (policy.resetPassword
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>