Compare commits

..
Author SHA1 Message Date
agent-exec-transport b496c4476d agent-exec transport 5cc70be566de95cb35d2d8bdfb05b392/b0fcba7575e5
governance / governance (pull_request) Successful in 22s
ci / test-and-build (pull_request) Successful in 43s
2026-10-20 03:54:20 +00:00
weichen d7768d4ea0 Merge pull request 'Switch account UI to administrator one-time passwords' (#114) from feature/auth-one-time-password-r2 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 27s
2026-10-20 01:56:52 +00:00
5 changed files with 343 additions and 58 deletions
+89 -54
View File
@@ -24,11 +24,20 @@ interface MemberRow {
profRole?: { roleId: string; name: string } | null
}
export function RdmMembersSection({ project }: { project: Project }) {
export function RdmMembersSection({
project,
initialCanManage,
initialRows,
}: {
project: Project
initialCanManage?: boolean
initialRows?: MemberRow[]
}) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [canManage, setCanManage] = useState(initialCanManage ?? false)
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
const [directoryOk, setDirectoryOk] = useState(true)
@@ -54,15 +63,23 @@ export function RdmMembersSection({ project }: { project: Project }) {
const memberGen = useRef(0)
useEffect(() => {
setCanManage(false)
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
}, [project])
useEffect(() => {
if (!canManage) {
setRoleOptions(null)
return
}
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
.catch(() => { if (live) setRoleOptions(null) })
return () => { live = false }
}, [project])
}, [project, canManage])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
@@ -90,6 +107,9 @@ export function RdmMembersSection({ project }: { project: Project }) {
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
if (gen !== memberGen.current) return
if (!cursor) {
setCanManage(Boolean(out.canManage))
}
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
setMemberNext(out.next || '')
setMemberTotal(typeof out.total === 'number' ? out.total : null)
@@ -139,44 +159,48 @@ export function RdmMembersSection({ project }: { project: Project }) {
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
{canManage ? (
<div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
</select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
</select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
) : (
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
)}
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
onChange={e => setMemberQuery(e.target.value)} />
@@ -185,7 +209,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
</div>
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
<table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<thead>
<tr>
<th>成员</th>
<th>专业角色</th>
<th>项目身份</th>
<th>添加人</th>
<th>添加时间</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
@@ -194,21 +227,23 @@ export function RdmMembersSection({ project }: { project: Project }) {
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</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 || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>{' '}
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</td>
{canManage && (
<td>
<button className="ad-mini" disabled={busy || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>{' '}
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</td>
)}
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
{rows !== null && rows.length === 0 && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tbody>
</table>
{memberNext && rows !== null && (
+1
View File
@@ -212,6 +212,7 @@ export interface RdmMemberRow {
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
canManage?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
+1 -1
View File
@@ -1580,5 +1580,5 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
export function eventTime(e: { ts?: string }, now = Date.now()): number {
if (!e.ts) return now
const t = Date.parse(e.ts)
return Number.isNaN(t) ? now : t
return Number.isNaN(t) || t <= 0 ? now : t
}
+243
View File
@@ -0,0 +1,243 @@
// RDM 项目设置:成员页门控测试
// 当 canManage=false 时:添加成员入口与行操作按钮(移除/升降级)不出现,不调用 rdm.role.options;
// 当 canManage=true 时:添加成员与行操作按钮出现,请求 rdm.role.options。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { api } from '../src/api'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import type { Project } from '../src/contracts'
import type { RdmMemberRow } from '../src/rdmManagement'
const project: Project = {
id: 'prj_test',
name: '测试项目',
settings: {
'rdm.projectId': 'rdm_prj_test',
'rdm.state': 'ready',
},
createdAt: '2026-10-01T00:00:00Z',
updatedAt: '2026-10-01T00:00:00Z',
}
const mockRows: RdmMemberRow[] = [
{ owner: 'alice', name: 'Alice', role: 'owner', addedBy: 'admin', addedAt: '2026-10-01T10:00:00Z' },
{ owner: 'bob', name: 'Bob', role: 'member', addedBy: 'alice', addedAt: '2026-10-02T10:00:00Z' },
]
describe('RdmMembersSection 静态组件门控呈现', () => {
it('canManage=false 时:添加成员与移除按钮不呈现,显示只读提示,动作列头不渲染', () => {
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
project,
initialCanManage: false,
initialRows: mockRows,
}))
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
expect(html).not.toContain('添加成员')
expect(html).not.toContain('移除')
expect(html).not.toContain('降为成员')
expect(html).not.toContain('升为负责人')
// 列表基础内容、搜索框、成员信息仍可见
expect(html).toContain('alice')
expect(html).toContain('bob')
expect(html).toContain('搜索成员')
// 表头只有 5 列(无末尾操作列头 <th></th>)
const thMatches = html.match(/<th\b/g) || []
expect(thMatches).toHaveLength(5)
})
it('canManage=true 时:添加成员与移除按钮呈现,不显示只读提示,动作列头渲染', () => {
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
project,
initialCanManage: true,
initialRows: mockRows,
}))
expect(html).not.toContain('仅项目创建者可管理成员')
expect(html).toContain('添加成员')
expect(html).toContain('移除')
expect(html).toContain('降为成员')
expect(html).toContain('升为负责人')
// 表头共 6 列(包含末尾操作列头 <th></th>)
const thMatches = html.match(/<th\b/g) || []
expect(thMatches).toHaveLength(6)
})
})
describe('RdmMembersSection 动态加载与命令门控交互', () => {
let commandsCalled: { id: string; params?: Record<string, unknown> }[] = []
let mockEnv: { container: any; cleanup: () => void }
function setupMockDom() {
const origDoc = globalThis.document
const origWindow = globalThis.window
const origAct = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
;(globalThis as any).HTMLIFrameElement = class {}
;(globalThis as any).Element = class {}
;(globalThis as any).HTMLElement = class extends (globalThis as any).Element {}
function createMockElement(tag: string): any {
const el: any = {
nodeType: 1,
tagName: tag.toUpperCase(),
children: [],
style: {},
ownerDocument: doc,
get options() {
return el.children.filter((c: any) => c.tagName === 'OPTION')
},
get length() {
return el.children.filter((c: any) => c.tagName === 'OPTION').length
},
setAttribute(k: string, v: string) { el[k] = v },
removeAttribute(k: string) { delete el[k] },
getAttribute(k: string) { return el[k] },
appendChild(c: any) {
el.children.push(c)
c.parentNode = el
if (c.tagName === 'OPTION') {
const idx = el.children.filter((x: any) => x.tagName === 'OPTION').indexOf(c)
el[idx] = c
}
return c
},
removeChild(c: any) {
const i = el.children.indexOf(c)
if (i >= 0) el.children.splice(i, 1)
delete c.parentNode
},
insertBefore(newNode: any, refNode: any) {
const i = el.children.indexOf(refNode)
if (i >= 0) el.children.splice(i, 0, newNode)
else el.children.push(newNode)
newNode.parentNode = el
return newNode
},
addEventListener() {},
removeEventListener() {},
}
return el
}
const doc: any = {
nodeType: 9,
createElement(tag: string) { return createMockElement(tag) },
createElementNS(_ns: string, tag: string) { return createMockElement(tag) },
createTextNode(text: string) {
return { nodeType: 3, nodeValue: text, ownerDocument: doc }
},
createComment(text: string) {
return { nodeType: 8, nodeValue: text, ownerDocument: doc }
},
addEventListener() {},
removeEventListener() {},
defaultView: globalThis,
}
doc.ownerDocument = doc
globalThis.document = doc
globalThis.window = globalThis as any
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
return {
container: createMockElement('div'),
cleanup() {
globalThis.document = origDoc
globalThis.window = origWindow
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = origAct
},
}
}
function serializeDom(el: any): string {
if (!el) return ''
if (el.nodeType === 3) return el.nodeValue || ''
if (el.nodeType === 8) return ''
let out = `<${el.tagName.toLowerCase()}`
if (el.className) out += ` class="${el.className}"`
out += '>'
if (el.textContent && el.children.length === 0) {
out += el.textContent
} else {
for (const c of el.children) out += serializeDom(c)
}
out += `</${el.tagName.toLowerCase()}>`
return out
}
beforeEach(() => {
commandsCalled = []
mockEnv = setupMockDom()
})
afterEach(() => {
mockEnv.cleanup()
vi.restoreAllMocks()
})
it('当 rdm.project.member.list 返回 canManage=false 时:不请求 rdm.role.options,添加成员与移除按钮缺席', async () => {
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
commandsCalled.push({ id, params })
if (id === 'rdm.project.member.list') {
return { members: mockRows, canManage: false, total: 2 } as any
}
if (id === 'rdm.role.options') {
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
}
return {} as any
})
const root = createRoot(mockEnv.container)
await act(async () => {
root.render(createElement(RdmMembersSection, { project }))
})
// 等待 useEffect 及微任务完成
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
const html = serializeDom(mockEnv.container)
// 证明请求了成员列表,但未请求 rdm.role.options
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(false)
// 证明添加成员和移除按钮缺席,显示只读提示
expect(html).not.toContain('添加成员')
expect(html).not.toContain('移除')
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
})
it('当 rdm.project.member.list 返回 canManage=true 时:触发 rdm.role.options 请求,呈现添加成员与移除按钮', async () => {
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
commandsCalled.push({ id, params })
if (id === 'rdm.project.member.list') {
return { members: mockRows, canManage: true, total: 2 } as any
}
if (id === 'rdm.role.options') {
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
}
return {} as any
})
const root = createRoot(mockEnv.container)
await act(async () => {
root.render(createElement(RdmMembersSection, { project }))
})
// 等待 useEffect 及微任务完成
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
const html = serializeDom(mockEnv.container)
// 证明请求了成员列表,且在 canManage=true 后请求了 rdm.role.options
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(true)
// 证明添加成员和移除按钮存在
expect(html).toContain('添加成员')
expect(html).toContain('移除')
expect(html).not.toContain('仅项目创建者可管理成员')
})
})
+9 -3
View File
@@ -26,12 +26,18 @@ describe('mergeStartedRun', () => {
})
describe('eventTime', () => {
it('parses the server timestamp', () => {
expect(eventTime({ ts: '2026-10-03T14:50:25.073Z' }, 0)).toBe(Date.parse('2026-10-03T14:50:25.073Z'))
it('parses normal ISO server timestamps', () => {
const iso = '2026-10-03T14:50:25.073Z'
expect(eventTime({ ts: iso }, 0)).toBe(Date.parse(iso))
expect(eventTime({ ts: iso }, 42)).toBe(Date.parse(iso))
})
it('falls back when the timestamp is missing or unparseable', () => {
it('falls back to now when timestamp is missing, invalid or zero-time 0001-01-01T00:00:00Z', () => {
expect(eventTime({}, 42)).toBe(42)
expect(eventTime({ ts: undefined }, 42)).toBe(42)
expect(eventTime({ ts: '' }, 42)).toBe(42)
expect(eventTime({ ts: 'not-a-time' }, 42)).toBe(42)
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, 42)).toBe(42)
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, 42)).toBe(42)
})
})