diff --git a/changelog.d/admin-support-view.added.md b/changelog.d/admin-support-view.added.md new file mode 100644 index 0000000..fc7ed91 --- /dev/null +++ b/changelog.d/admin-support-view.added.md @@ -0,0 +1 @@ +Admins now have a read-only support view for the users they are allowed to see, including that user's repositories, recent jobs and audit entries. The same view opens a job without the control buttons, so support can inspect a run without changing it or triggering new work. diff --git a/docs/architecture.md b/docs/architecture.md index a6787cd..8c4d601 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,9 @@ normal product UI plus a scoped management page for themselves and normal users; the super admin sees everyone, can set admins and is the only role that can set unlimited quota. The owner can toggle a normal-user preview cookie that - renders the simple UI while advanced routes stay blocked. + renders the simple UI while advanced routes stay blocked. An admin can open a + read-only support view for a visible user and that user's jobs; it never + mutates data or triggers work. - **No worker process**: a 60-second tick schedules due iterate jobs and, when the user selected the maintainer role and the upstream grants admin/maintain permission, review jobs for all open PRs. The interval is the minimum gap between runs, not a cap on PRs or issues; a diff --git a/src/app.ts b/src/app.ts index 3a60782..c96c7ff 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,7 @@ import { isWorkItemState, MAINTAINER_ONLY_STATES, WORK_ITEM_TRANSITIONS, type Wo import { Approvals } from './users/approval.js' import { adminPage, + adminSupportPage, approvalPage, dashboardPage, landingPage, @@ -1814,6 +1815,52 @@ export async function buildApp(options: BuildOptions): Promise { })) }) + const adminCanViewUser = (viewer: { user: UserRow; preview?: boolean }, user: UserRow): boolean => { + if (viewer.preview === true) return false + if (viewer.user.role === 'superadmin') return true + return user.id === viewer.user.id || user.role === 'user' + } + + app.get('/admin/users/:id', async (request, reply) => { + const viewer = requireAdmin(request) + if (viewer.preview === true) return reply.redirect('/console') + const user = store.userById(String((request.params as Record).id)) + if (user === undefined || !adminCanViewUser(viewer, user)) throw httpError(404, '用户不存在。', 'USER_NOT_FOUND') + store.audit(viewer.user.id, 'admin.support_view', `user:${user.id}`) + return reply.type('text/html').send(adminSupportPage({ + config, + locale: request.locale ?? 'zh', + viewer: viewOf(viewer), + user, + repos: store.listRepos(user.id), + jobs: store.listJobsPageForUser(user.id, 20, 0), + audit: store.listAuditPageForUsers([user.id], 20, 0), + notice: queryText(request, 'notice'), + error: queryText(request, 'error'), + })) + }) + + app.get('/admin/jobs/:jobId', async (request, reply) => { + const viewer = requireAdmin(request) + if (viewer.preview === true) return reply.redirect('/console') + const job = store.getJob(String((request.params as Record).jobId)) + if (job === undefined) throw httpError(404, '任务不存在。', 'JOB_NOT_FOUND') + const repo = store.getRepo(job.repo_id) + const user = store.userById(job.user_id) + if (repo === undefined || user === undefined || !adminCanViewUser(viewer, user)) throw httpError(404, '任务不存在。', 'JOB_NOT_FOUND') + store.audit(viewer.user.id, 'admin.support_view', `job:${job.id}`) + return reply.type('text/html').send(jobPage({ + config, + locale: request.locale ?? 'zh', + viewer: viewOf(viewer), + repo, + job, + supportMode: true, + notice: queryText(request, 'notice'), + error: queryText(request, 'error'), + })) + }) + app.get('/admin', async (request, reply) => { const viewer = requireAdmin(request) if (viewer.preview === true) return reply.redirect('/console') diff --git a/src/views/pages.ts b/src/views/pages.ts index 878ebb0..965d0e5 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -1103,7 +1103,7 @@ export function adminPage(input: { const userRows = users .map( (user) => ` - @${escapeHtml(user.login)}
${escapeHtml(user.email ?? '')} + @${escapeHtml(user.login)}
${escapeHtml(user.email ?? '')}
${escapeHtml(t('支持视图', 'Support view'))} ${badge(t, user.status)} ${escapeHtml(roleLabel(t, user.role))} ${user.quota_unlimited === 1 ? escapeHtml(t('不限额', 'Unlimited')) : money(user.quota_cny)} @@ -1363,10 +1363,12 @@ export function jobPage(input: { repo: RepoRow job: JobRow queuePosition?: number + supportMode?: boolean notice?: string error?: string }): string { const { config, locale, viewer, repo, job, queuePosition, notice, error } = input + const supportMode = input.supportMode === true const t = translator(locale) const advanced = advancedViewer(viewer) const evidence = parseEvidenceJson(job.evidence_json) @@ -1415,15 +1417,15 @@ export function jobPage(input: {
${escapeHtml(t('返回仓库', 'Back to repository'))} - ${advanced ? `${escapeHtml(t('工作区', 'Workspace'))} + ${advanced && !supportMode ? `${escapeHtml(t('工作区', 'Workspace'))} ${escapeHtml(t('团队工作台', 'Team console'))}` : ''} - ${control} - ${execute} - ${retry} + ${supportMode ? '' : control} + ${supportMode ? '' : execute} + ${supportMode ? '' : retry}
- ${planCard} + ${supportMode ? '' : planCard}

${escapeHtml(t('结果', 'Result'))}

@@ -1626,3 +1628,31 @@ export function agentWorkspacePage(input: {

Fledge · powered by FlyBot

` return layout({ title: `${t('工作区', 'Workspace')} · ${repo.upstream_name || repo.name}`, viewer, body, config, locale, path: `/repos/${repo.id}/workspace`, active: 'dashboard', notice, error }) } + +export function adminSupportPage(input: { + config: Config + locale: Locale + viewer: Viewer + user: UserRow + repos: RepoRow[] + jobs: JobRow[] + audit: AuditRow[] + notice?: string + error?: string +}): string { + const { config, locale, viewer, user, repos, jobs, audit, notice, error } = input + const t = translator(locale) + const usersById = new Map([[user.id, user.login]]) + const repoRows = repos.length === 0 + ? `${escapeHtml(t('没有仓库。', 'No repositories.'))}` + : repos.map((repo) => `${escapeHtml(repo.upstream_owner || repo.owner)}/${escapeHtml(repo.upstream_name || repo.name)}${escapeHtml(repoRoleLabel(t, repo.repo_role))}${badge(t, repo.last_status ?? 'idle')}${escapeHtml(humanTimeShort(repo.last_run_at))}`).join('') + const jobRows = jobs.length === 0 + ? `${escapeHtml(t('没有任务。', 'No jobs.'))}` + : jobs.map((job) => `${escapeHtml(job.id)}${badge(t, job.status)} ${escapeHtml(jobKindLabel(t, job))}${escapeHtml(job.pr_url ?? '—')}${escapeHtml(humanTimeShort(job.created_at))}`).join('') + const body = `← ${escapeHtml(t('返回管理', 'Back to admin'))} +
${escapeHtml(t('支持视图', 'SUPPORT VIEW'))}

@${escapeHtml(user.login)}

${badge(t, user.status)} · ${escapeHtml(roleLabel(t, user.role))} · ${escapeHtml(t('加入于', 'Joined'))} ${escapeHtml(humanTimeShort(user.created_at))}

${escapeHtml(t('只读;不会修改用户数据或触发任务。', 'Read-only; no user data or jobs are changed.'))}

+

${escapeHtml(t('仓库', 'Repositories'))}

${repoRows}
${escapeHtml(t('仓库', 'Repository'))}${escapeHtml(t('角色', 'Role'))}${escapeHtml(t('状态', 'Status'))}${escapeHtml(t('上次运行', 'Last run'))}
+

${escapeHtml(t('最近任务', 'Recent jobs'))}

${jobRows}
${escapeHtml(t('任务', 'Job'))}${escapeHtml(t('状态', 'Status'))}PR${escapeHtml(t('创建', 'Created'))}
+

${escapeHtml(t('审计', 'Audit'))}

${auditRows(locale, audit, usersById, true)}
` + return layout({ title: `${t('支持视图', 'Support view')} @${user.login}`, viewer, body, config, locale, path: `/admin/users/${user.id}`, active: 'admin', notice, error }) +} diff --git a/test/admin.test.ts b/test/admin.test.ts index b1c1851..5df4bf7 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -201,3 +201,34 @@ test('the configured primary login is promoted to owner on startup', async () => await built.close() } }) + +test('admins can inspect a normal user read-only from a support view', async () => { + const config = testConfig() + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const admin = makeUser(store, { login: 'support-admin', admin: true }) + const user = makeUser(store, { login: 'support-user', status: 'approved' }) + const otherAdmin = makeUser(store, { login: 'other-admin', admin: true }) + const repo = store.createRepo({ id: 'repo_support', userId: user.id, owner: 'o', name: 'support', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: 'fix', autoIterate: false, intervalMinutes: 1440 }) + store.createJob({ id: 'job_support', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'support goal', callbackHash: 'cb', platformTokenHash: '', kind: 'iterate' }) + store.audit(user.id, 'repo.settings', 'support detail') + const { cookie } = sessionFor(built, admin) + const support = await built.app.inject({ method: 'GET', url: `/admin/users/${user.id}`, headers: { cookie } }) + assert.equal(support.statusCode, 200) + assert.match(support.body, /@support-user/) + assert.match(support.body, /只读/) + const job = await built.app.inject({ method: 'GET', url: '/admin/jobs/job_support', headers: { cookie } }) + assert.equal(job.statusCode, 200) + assert.match(job.body, /job_support/) + assert.doesNotMatch(job.body, /取消这个任务|重试这个任务|执行这个计划/) + const hidden = await built.app.inject({ method: 'GET', url: `/admin/users/${otherAdmin.id}`, headers: { cookie } }) + assert.equal(hidden.statusCode, 404) + const normal = makeUser(store, { login: 'normal-user', status: 'approved' }) + const normalSession = sessionFor(built, normal) + const denied = await built.app.inject({ method: 'GET', url: `/admin/users/${user.id}`, headers: { cookie: normalSession.cookie } }) + assert.equal(denied.statusCode, 403) + } finally { + await built.close() + } +})