Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/admin-support-view.added.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1814,6 +1815,52 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
}))
})

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<string, string>).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<string, string>).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')
Expand Down
42 changes: 36 additions & 6 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,7 @@ export function adminPage(input: {
const userRows = users
.map(
(user) => `<tr>
<td>@${escapeHtml(user.login)}<br /><small class="muted">${escapeHtml(user.email ?? '')}</small></td>
<td>@${escapeHtml(user.login)}<br /><small class="muted">${escapeHtml(user.email ?? '')}</small><br /><a class="link" href="/admin/users/${escapeHtml(user.id)}">${escapeHtml(t('支持视图', 'Support view'))}</a></td>
<td>${badge(t, user.status)}</td>
<td>${escapeHtml(roleLabel(t, user.role))}</td>
<td>${user.quota_unlimited === 1 ? escapeHtml(t('不限额', 'Unlimited')) : money(user.quota_cny)}</td>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1415,15 +1417,15 @@ export function jobPage(input: {
</div>
<div class="row">
<a class="btn btn-ghost" href="/repos/${escapeHtml(repo.id)}">${escapeHtml(t('返回仓库', 'Back to repository'))}</a>
${advanced ? `<a class="btn btn-ghost" href="/repos/${escapeHtml(repo.id)}/workspace">${escapeHtml(t('工作区', 'Workspace'))}</a>
${advanced && !supportMode ? `<a class="btn btn-ghost" href="/repos/${escapeHtml(repo.id)}/workspace">${escapeHtml(t('工作区', 'Workspace'))}</a>
<a class="btn btn-ghost" href="/repos/${escapeHtml(repo.id)}/team">${escapeHtml(t('团队工作台', 'Team console'))}</a>` : ''}
${control}
${execute}
${retry}
${supportMode ? '' : control}
${supportMode ? '' : execute}
${supportMode ? '' : retry}
</div>
</div>
</section>
${planCard}
${supportMode ? '' : planCard}
<section class="cols">
<div class="card">
<h2>${escapeHtml(t('结果', 'Result'))}</h2>
Expand Down Expand Up @@ -1626,3 +1628,31 @@ export function agentWorkspacePage(input: {
<p class="fine" style="text-align:right">Fledge · powered by FlyBot</p>`
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
? `<tr><td colspan="4" class="muted">${escapeHtml(t('没有仓库。', 'No repositories.'))}</td></tr>`
: repos.map((repo) => `<tr><td><strong>${escapeHtml(repo.upstream_owner || repo.owner)}/${escapeHtml(repo.upstream_name || repo.name)}</strong></td><td><span class="badge kind">${escapeHtml(repoRoleLabel(t, repo.repo_role))}</span></td><td>${badge(t, repo.last_status ?? 'idle')}</td><td>${escapeHtml(humanTimeShort(repo.last_run_at))}</td></tr>`).join('')
const jobRows = jobs.length === 0
? `<tr><td colspan="4" class="muted">${escapeHtml(t('没有任务。', 'No jobs.'))}</td></tr>`
: jobs.map((job) => `<tr><td><a href="/admin/jobs/${escapeHtml(job.id)}"><code>${escapeHtml(job.id)}</code></a></td><td>${badge(t, job.status)} <span class="badge kind">${escapeHtml(jobKindLabel(t, job))}</span></td><td>${escapeHtml(job.pr_url ?? '—')}</td><td>${escapeHtml(humanTimeShort(job.created_at))}</td></tr>`).join('')
const body = `<a class="back" href="/admin">← ${escapeHtml(t('返回管理', 'Back to admin'))}</a>
<section class="card"><div class="eyebrow">${escapeHtml(t('支持视图', 'SUPPORT VIEW'))}</div><h1>@${escapeHtml(user.login)}</h1><p class="muted">${badge(t, user.status)} · ${escapeHtml(roleLabel(t, user.role))} · ${escapeHtml(t('加入于', 'Joined'))} ${escapeHtml(humanTimeShort(user.created_at))}</p><p class="fine">${escapeHtml(t('只读;不会修改用户数据或触发任务。', 'Read-only; no user data or jobs are changed.'))}</p></section>
<section class="card"><h2>${escapeHtml(t('仓库', 'Repositories'))}</h2><div class="table-scroll"><table><thead><tr><th>${escapeHtml(t('仓库', 'Repository'))}</th><th>${escapeHtml(t('角色', 'Role'))}</th><th>${escapeHtml(t('状态', 'Status'))}</th><th>${escapeHtml(t('上次运行', 'Last run'))}</th></tr></thead><tbody>${repoRows}</tbody></table></div></section>
<section class="card"><h2>${escapeHtml(t('最近任务', 'Recent jobs'))}</h2><div class="table-scroll"><table><thead><tr><th>${escapeHtml(t('任务', 'Job'))}</th><th>${escapeHtml(t('状态', 'Status'))}</th><th>PR</th><th>${escapeHtml(t('创建', 'Created'))}</th></tr></thead><tbody>${jobRows}</tbody></table></div></section>
<section class="card"><h2>${escapeHtml(t('审计', 'Audit'))}</h2>${auditRows(locale, audit, usersById, true)}</section>`
return layout({ title: `${t('支持视图', 'Support view')} @${user.login}`, viewer, body, config, locale, path: `/admin/users/${user.id}`, active: 'admin', notice, error })
}
31 changes: 31 additions & 0 deletions test/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
Loading