feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI]

This commit is contained in:
justme committed 2026-10-07 20:56:36 +08:00
1 parent 55b80f0a97
commit ddb6608e32
38 files changed
+1148 -218

No files matched your search

+105 -26
View File
@@ -1,12 +1,13 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
@@ -304,47 +305,93 @@ function Versions() {
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
const catalog = useQuery(() => api.get<CapabilityCatalogDto>('/api/admin/capabilities'), [])
const me = useQuery(() => api.get<MeDto>('/api/admin/auth/me'), [])
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [scope, setScope] = useState<'read' | 'admin' | 'release'>('read')
const [days, setDays] = useState<number | null>(null)
const [caps, setCaps] = useState<Set<string>>(new Set())
const [created, setCreated] = useState<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto[]>()
const [picked, setPicked] = useState<Set<string>>(new Set())
const items = tokens.data?.items ?? []
const active = items.filter((t) => !t.revoked_at)
const allPicked = active.length > 0 && active.every((t) => picked.has(t.id))
const mine = useMemo(() => new Set(me.data?.capabilities ?? []), [me.data])
const trust = useMemo(() => new Set((catalog.data?.groups ?? []).flatMap((g) => g.capabilities.filter((c) => c.trust).map((c) => c.id))), [catalog.data])
const open = () => {
setName('')
setDays(null)
setCaps(new Set())
setCreating(true)
}
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。</span>
<button type="button" className="btn primary right" onClick={() => setCreating(true)}>
<Plus size={18} />
创建令牌
</button>
<span className="muted">给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。</span>
<span className="right row">
{picked.size > 0 ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(active.filter((t) => picked.has(t.id)))}>
<Trash2 size={14} />
吊销所选({picked.size})
</button>
) : null}
<button type="button" className="btn primary" onClick={open}>
<Plus size={18} />
创建令牌
</button>
</span>
</div>
<ErrorNotice error={tokens.error} />
{(tokens.data?.items ?? []).length === 0 ? (
{items.length === 0 ? (
<Empty>没有 API 令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th style={{ width: 28 }}>
<input type="checkbox" aria-label="全选" checked={allPicked} onChange={(e) => setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} />
</th>
<th>名称</th>
<th>权限</th>
<th>能力</th>
<th>过期</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => (
{items.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td>
<Chip tone={t.scope === 'admin' ? 'warn' : ''}>{t.scope}</Chip>
<input
type="checkbox"
aria-label={`选择 ${t.name}`}
disabled={!!t.revoked_at}
checked={picked.has(t.id)}
onChange={(e) => {
const next = new Set(picked)
if (e.target.checked) next.add(t.id)
else next.delete(t.id)
setPicked(next)
}}
/>
</td>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td title={t.capabilities.map(capabilityName).join('、')}>
<Chip tone={t.capabilities.some((c) => trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项</Chip>{' '}
<span className="faint">
{t.capabilities.slice(0, 2).map(capabilityName).join('、')}
{t.capabilities.length > 2 ? '…' : ''}
</span>
</td>
<td className="muted">{t.expires_at ? fmtTime(t.expires_at) : '不过期'}</td>
<td className="muted">{fmtTime(t.created_at)}</td>
<td className="muted">{t.revoked_at ? <Chip tone="bad">已吊销</Chip> : fmtTime(t.last_used_at)}</td>
<td className="r">
{!t.revoked_at ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<button type="button" className="btn small danger" onClick={() => setRevoking([t])}>
<Trash2 size={14} />
吊销
</button>
@@ -367,16 +414,15 @@ function Tokens() {
<button
type="button"
className="btn primary"
disabled={!name.trim()}
disabled={!name.trim() || caps.size === 0}
onClick={async () => {
let result: CreatedApiTokenDto | undefined
const ok = await run(async () => {
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), scope })
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), capabilities: [...caps], expires_in_days: days })
})
if (ok && result) {
setCreated(result)
setCreating(false)
setName('')
tokens.reload()
}
}}
@@ -387,29 +433,62 @@ function Tokens() {
}
>
<div className="stack">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="权限">
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }, { value: 'release' as const, label: 'release(只登记发布,给 CI)' }]} value={scope} onChange={setScope} />
<div className="form-grid">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="有效期">
<select className="input" value={days ?? ''} onChange={(e) => setDays(e.target.value ? Number(e.target.value) : null)}>
<option value="">不过期</option>
<option value="1">1 天</option>
<option value="7">7 天</option>
<option value="30">30 天</option>
<option value="90">90 天</option>
<option value="365">365 天</option>
</select>
</Field>
</div>
<Field label="能力" hint="写蕴含读:勾选“修改”会自动带上对应的“查看”。灰色的是你自己没有的能力,不能授予。">
{catalog.data ? <CapabilityPicker catalog={catalog.data} mine={mine} value={caps} onChange={setCaps} /> : <Spinner />}
</Field>
</div>
</Dialog>
) : null}
{created ? (
<Dialog title="令牌已创建" onClose={() => setCreated(undefined)} actions={<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>完成</button>}>
<Dialog
title="令牌已创建"
onClose={() => setCreated(undefined)}
actions={
<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} />
</div>
<p className="muted">
{created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'}
</p>
</div>
</Dialog>
) : null}
{revoking ? (
<Confirm title="吊销令牌" danger confirmText="吊销" onClose={() => setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}>
使用「{revoking.name}」的程序会立刻失去访问权限。
<Confirm
title="吊销令牌"
danger
confirmText={`吊销 ${revoking.length} 个`}
onClose={() => setRevoking(undefined)}
onConfirm={async () => {
for (const t of revoking) await run(() => api.del(`/api/admin/api-tokens/${t.id}`), `已吊销 ${t.name}`)
setPicked(new Set())
tokens.reload()
}}
>
使用 {revoking.map((t) => `「${t.name}」`).join('、')} 的程序会立刻失去访问权限。
</Confirm>
) : null}
</div>