Files
MoShiro/web/admin/src/pages/CapabilityPicker.tsx
T
justme 0f8f3f9a54
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: trusted release keys setting; drop the duplicate release registration route [CI]
The panel now lets the deployer decide which release keys it trusts. The
official CI key is built in and always trusted; an own P-256 public key can be
added (off by default, the private key is rejected) so manifests signed with it
are accepted too. Removing it drops the manifests only it signed. Changes are
audited and need update.confirm.

Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on
generating a key pair and signing a manifest with openssl.

Registration now has one path, POST /api/release/offer (no token, signature
verified). The admin POST /releases route, the release.publish capability and
the ci-release preset are removed.
2026-10-08 18:50:16 +08:00

171 lines
7.6 KiB
TypeScript

import { KeyRound } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CapabilityCatalogDto } from '@shared/api/types'
import { Chip } from '@shared/ui'
/** 能力的中文名和说明。目录(分组、依赖、预设)来自后端,这里只管显示文字。 */
const NAMES: Record<string, [string, string]> = {
'public.read': ['查看展示数据', '看隐藏线路;站点私有时看展示页'],
'fleet.read': ['查看节点', 'Agent、Probe、线路、加入代码'],
'fleet.write': ['修改节点', '改名、标签、可见性、地址、线路开关、断开会话'],
'fleet.enroll': ['添加节点', '生成加入代码、批准新节点'],
'fleet.revoke': ['吊销或删除节点', '破坏性操作'],
'tasks.read': ['查看任务', '任务模板、覆盖、测试记录'],
'tasks.write': ['修改任务', '创建、修改、删除任务;发起或取消测试'],
'data.read': ['读取原始数据', '原始块、导出'],
'data.write': ['维护数据', '重算分析、清理'],
'data.export': ['批量导出数据', '预留:按线路和时间批量拉取,带配额'],
'settings.read': ['查看设置', '站点设置、保留策略'],
'settings.write': ['修改设置', '站点设置、保留策略、重启计划'],
'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'],
'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'],
'keys.read': ['查看 API 令牌', ''],
'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'],
'accounts.manage': ['管理账户', '面板用户、密码'],
'events.read': ['查看事件', '节点上下线等事件流'],
'audit.read': ['查看审计日志', ''],
'ops.metrics': ['读取 /metrics', 'Prometheus 指标'],
'webhooks.read': ['查看 webhook', '预留:外部回调订阅'],
'webhooks.manage': ['管理 webhook', '预留:创建、修改、删除回调订阅,触发测试回调'],
}
const GROUPS: Record<string, string> = {
public: '展示',
fleet: '节点',
tasks: '任务',
data: '数据',
settings: '设置',
update: '更新与发布',
keys: 'API 令牌',
accounts: '账户',
ops: '运维与审计',
integrations: '集成(预留)',
}
const PRESETS: Record<string, string> = {
'read-only': '只读',
operations: '运维',
enroll: '接入',
'ci-release': 'CI 发布',
all: '全部',
}
/** 复选框:支持半选(indeterminate)。 */
function Check({ checked, partial, disabled, onChange, label }: { checked: boolean; partial?: boolean; disabled?: boolean; onChange: (on: boolean) => void; label: React.ReactNode }) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = !!partial && !checked
}, [partial, checked])
return (
<label className="switch">
<input ref={ref} type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
/**
* 能力选择器:分组复选框 + 预设 + 全选/组内全选(带半选)/反选 + 搜索。
* `mine` 是创建者自己拥有的能力:不在里面的灰色不可选(不能授予自己没有的);写蕴含读,勾选写时读会一起被勾上。
*/
export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog: CapabilityCatalogDto; mine: ReadonlySet<string>; value: ReadonlySet<string>; onChange: (next: Set<string>) => void }) {
const [query, setQuery] = useState('')
const info = useMemo(() => new Map(catalog.groups.flatMap((g) => g.capabilities.map((c) => [c.id, c] as const))), [catalog])
const selectable = (id: string) => mine.has(id)
// 勾选一项:补上它蕴含的;取消一项:同时取消依赖它的(写依赖读)
const closure = (ids: Iterable<string>): Set<string> => {
const out = new Set<string>()
const add = (id: string) => {
if (out.has(id) || !selectable(id)) return
out.add(id)
info.get(id)?.implies.forEach(add)
}
for (const id of ids) add(id)
return out
}
const set = (ids: Iterable<string>) => onChange(closure(ids))
const toggle = (id: string, on: boolean) => {
if (on) return set([...value, id])
const next = new Set(value)
next.delete(id)
// 依赖被取消的项一起取消
let changed = true
while (changed) {
changed = false
for (const x of next) if (info.get(x)?.implies.some((i) => !next.has(i))) { next.delete(x); changed = true }
}
onChange(next)
}
const needle = query.trim().toLowerCase()
const visible = (id: string) => !needle || id.includes(needle) || (NAMES[id]?.[0] ?? '').includes(needle) || (NAMES[id]?.[1] ?? '').includes(needle)
const all = [...info.keys()].filter(selectable)
const allOn = all.length > 0 && all.every((id) => value.has(id))
return (
<div>
<div className="cap-toolbar stack" style={{ gap: 8 }}>
<div className="row wrap" style={{ gap: 6 }}>
<span className="muted">预设:</span>
{catalog.presets.map((p) => (
<button key={p.id} type="button" className="btn small" onClick={() => set(p.capabilities.filter(selectable))}>
{PRESETS[p.id] ?? p.id}
</button>
))}
<input className="input" style={{ maxWidth: 220, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="row wrap" style={{ gap: 12 }}>
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
反选
</button>
<span className="muted">已选 {value.size} / {all.length} 项</span>
</div>
</div>
<div className="cap-grid">
{catalog.groups.map((g) => {
const rows = g.capabilities.filter((c) => visible(c.id))
if (rows.length === 0) return null
const own = g.capabilities.filter((c) => selectable(c.id))
const on = own.filter((c) => value.has(c.id))
return (
<div key={g.id} className="cap-group">
<Check
checked={own.length > 0 && on.length === own.length}
partial={on.length > 0}
disabled={own.length === 0}
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
label={<b>{GROUPS[g.id] ?? g.id}</b>}
/>
<div style={{ marginTop: 6 }}>
{rows.map((c) => (
<div key={c.id} className="cap-row">
<div className="row" style={{ gap: 8 }}>
<Check checked={value.has(c.id)} disabled={!selectable(c.id)} onChange={(next) => toggle(c.id, next)} label={NAMES[c.id]?.[0] ?? c.id} />
{c.trust ? (
<Chip tone="warn">
<KeyRound size={12} /> 信任类
</Chip>
) : null}
{c.reserved ? <Chip>预留</Chip> : null}
</div>
<div className="cap-meta faint">
<span className="mono">{c.id}</span>
{NAMES[c.id]?.[1] ?? ''}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
export function capabilityName(id: string): string {
return NAMES[id]?.[0] ?? id
}