设置里的「访问密钥」与钩子的收件记录(下一轮第 2 波 W2-d 前端,细案 04 A1、A6、A7)

- 设置 → 访问密钥:管理员看个人访问密钥的开关、服务账号(建、停用前确认)和全部密钥;别人只看自己的个人访问密钥,管理员
  打开开关之后才有这一页(认人接口多给一项 personal_keys:关着时建不了、已有的也不生效,给一页空的没有用)。新建时缺省只勾读
  的权限,服务账号勾不了回复待办,有效期必填最长 365 天;明文只显示这一次,点「我已保存」就没了;轮换、撤销先确认;关登录时
  照实说访问密钥不生效、不给建的入口
- 「最近的请求」做成共用组件(components/RecentRequests.tsx):每把密钥的请求记录、钩子的收件记录都用它,接口写路由模板,
  成功、被拒、处理中分开上色
- 接口说明:照服务端门禁那张表列出方法、路径、权限、说明、错误码,标出要幂等键的
- 应用编辑 → 钩子:生成过密钥的钩子能展开收件记录(状态、原因、发起的实例),写明近 1 小时验签失败几次
- 名录里的服务账号:选人控件缺省不收(岗位绑定、改派不能派给它),应用授权照收;身份页那一行标出服务账号,不给角色和密码的
  控件;审计按「访问密钥」筛
- 检查:新建 check-access-keys(不叫 check-tokens,那个名字查的是设计令牌),check-app-editor 的钩子一段加收件记录;登记进
  check-all
This commit is contained in:
ThearchyHelios
2026-10-16 14:11:30 +08:00
parent 19329c873e
commit 0b825f40ee
18 changed files with 1388 additions and 37 deletions
+4 -1
View File
@@ -36,9 +36,12 @@ _OIDC_FIELDS = ("issuer", "client_id", "client_secret", "scopes", "username_clai
def principal_out(p: Principal) -> dict[str, Any]:
from app.auth import personal_keys_enabled
# personal_keys:管理员开没开个人访问密钥(next-round 04 A1),不是管理员的人在设置里据此给不给「访问密钥」一页
return {"mode": p.mode, "username": p.username, "display_name": p.display_name or p.username,
"role": p.role or "user", "groups": list(p.groups), "verified": p.verified,
"user_can_chat": user_can_chat()}
"user_can_chat": user_can_chat(), "personal_keys": personal_keys_enabled()}
async def principal_full(session: AsyncSession, p: Principal) -> dict[str, Any]:
+1 -1
View File
@@ -309,7 +309,7 @@ KEY_ROUTES: dict[tuple[str, str], KeyRoute] = {
(*_APP, *_PUBLISHED, *_IDEM, "app_entry_not_found", "app_needs_upgrade", "form_invalid", KEY_FILES_UNSUPPORTED,
"run_launch_refused"), idempotent=True),
("POST", "/api/apps/{app_id}/views/{view_id}/refresh"): KeyRoute(
"apps.run", "按需视图按参数运行一次(命中缓存或接上同一组参数正在跑的那次就不再发起)。必须带 Idempotency-Key;"
"apps.run", "按需视图按参数运行一次(命中缓存或接上同一组参数正在运行的那次就不再发起)。必须带 Idempotency-Key;"
"沿用每个应用每天的次数上限",
(*_APP, *_PUBLISHED, *_IDEM, "app_view_refused", "app_needs_upgrade", "app_view_daily_limit", "form_invalid",
"run_launch_refused"), idempotent=True),
+32
View File
@@ -0,0 +1,32 @@
/**
* 访问密钥与服务账号(/api/access-keys,docs/design/next-round/04-对外集成与触达.md A1–A7),挂在 api.accessKeys 上。形状见
* typesApp.ts。密钥明文只在建、轮换的回包里(key)出现一次,之后哪个接口都拿不到。
*/
import type {
AccessKeyIssued, AccessKeyReference, AccessKeyRequestRow, AccessKeyRow, AccessKeyScope, AccessKeySettings,
ServiceAccountRow,
} from '../typesApp'
import { get, patch, post, put } from './http'
const at = (id: string) => `/access-keys/${encodeURIComponent(id)}`
export const accessKeys = {
settings: () => get<AccessKeySettings>('/access-keys/settings'),
/** 个人访问密钥的开关(只给管理员) */
setPersonalKeys: (personal_keys: boolean) => put<AccessKeySettings>('/access-keys/settings', { personal_keys }),
/** 管理员:全部密钥;别人:自己的 */
list: async (): Promise<AccessKeyRow[]> => (await get<{ items: AccessKeyRow[] }>('/access-keys')).items,
/** 不给 service_account_id 是建自己的个人访问密钥 */
create: (body: { name: string; scopes: AccessKeyScope[]; app_ids?: string[] | null; expires_in_days: number;
service_account_id?: string }) => post<AccessKeyIssued>('/access-keys', body),
revoke: (id: string) => post<AccessKeyRow>(`${at(id)}/revoke`),
rotate: (id: string) => post<AccessKeyIssued>(`${at(id)}/rotate`),
requests: (id: string) => get<{ items: AccessKeyRequestRow[]; retain_days: number }>(`${at(id)}/requests`),
reference: () => get<AccessKeyReference>('/access-keys/reference'),
serviceAccounts: async (): Promise<ServiceAccountRow[]> =>
(await get<{ items: ServiceAccountRow[] }>('/access-keys/service-accounts')).items,
createServiceAccount: (body: { username: string; display_name?: string }) =>
post<ServiceAccountRow>('/access-keys/service-accounts', body),
updateServiceAccount: (id: string, body: { display_name?: string; disabled?: boolean }) =>
patch<ServiceAccountRow>(`/access-keys/service-accounts/${encodeURIComponent(id)}`, body),
}
+3 -1
View File
@@ -5,7 +5,7 @@
* 入口提交校验失败回 422,ApiError 的 body 里带 {errors: FormError[]}。
*/
import type {
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel,
} from '../typesApp'
import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
@@ -72,6 +72,8 @@ export const apps = {
setHookEnabled: (id: string, name: string, enabled: boolean) => patch<AppHookRow>(hookAt(id, name), { enabled }),
/** 删掉这个名字的密钥行:外部系统再用旧密钥签的请求一律拒收 */
deleteHook: (id: string, name: string) => del(hookAt(id, name)),
/** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */
hookReceipts: (id: string, name: string) => get<HookReceipts>(`${hookAt(id, name)}/receipts`),
/**
* 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次;
* 老服务端只回对话本身时由调用方再按 id 取一次
+3
View File
@@ -28,6 +28,7 @@ import { reportExport } from './reportExport'
import { apps, ledger } from './apps'
import { actions, tasks } from './tasks'
import { auth, identity } from './auth'
import { accessKeys } from './accessKeys'
import { capabilities, endpoints, notify } from './capabilities'
// 普通用户建应用(typesBlueprint.ts 是接口约定)
import { blueprints, files, personal, templates } from './blueprints'
@@ -907,6 +908,8 @@ export const api = {
actions,
auth,
identity,
// 访问密钥与服务账号(下一轮第 2 波)
accessKeys,
capabilities,
endpoints,
notify,
+22 -9
View File
@@ -37,6 +37,11 @@ export interface PrincipalPickerProps {
* 不看它
*/
source?: CandidateSource
/**
* 能不能选服务账号(docs/design/next-round/04-对外集成与触达.md A1):应用授权能(外部系统按授权读应用);岗位绑定、改派
* 不能(服务账号只持有访问密钥,回复不了待办)——缺省不能,名录里的服务账号不列、填了说清是服务账号
*/
allowService?: boolean
}
/** 边输入边取候选:停手这么久再去问,不是每敲一个字问一次 */
@@ -49,15 +54,20 @@ function loadDirectory(): Promise<Directory | null> {
return directory
}
/** 这一项在名录里对不上的地方:没有这个人或组(missing)、人停用了(disabled)。名录没取到、选的是所有人时不判 */
function problemOf(p: Principal, known: Directory | null): 'missing' | 'disabled' | null {
/**
* 这一项在名录里对不上的地方:没有这个人或组(missing)、人停用了(disabled)、是服务账号而这里不收(service)。名录没取到、
* 选的是所有人时不判
*/
function problemOf(p: Principal, known: Directory | null, allowService: boolean): 'missing' | 'disabled' | 'service' | null {
if (!known || p.kind === 'everyone') return null
if (p.kind === 'group') return known.groups.some((g) => g.name === p.name) ? null : 'missing'
const user = known.users.find((u) => u.username === p.name)
return !user ? 'missing' : user.disabled ? 'disabled' : null
return !user ? 'missing' : user.disabled ? 'disabled' : user.service && !allowService ? 'service' : null
}
export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label, source }: PrincipalPickerProps) {
export function PrincipalPicker({
value, onChange, allowEveryone = false, single = false, label, source, allowService = false,
}: PrincipalPickerProps) {
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const loginOn = useLoginOn()
// 名录给搭建者及以上(使用者读不到,不去取:取了也是 403)
@@ -112,12 +122,13 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
return
}
}
const problem = problemOf(item, known)
const problem = problemOf(item, known, allowService)
const soft = problem === 'missing' && oidc
if (problem && !(soft && anyway)) {
setRefused({
item, soft,
message: problem === 'disabled' ? PRINCIPAL_TEXT.disabledUser(item.name)
message: problem === 'service' ? PRINCIPAL_TEXT.serviceUser(item.name)
: problem === 'disabled' ? PRINCIPAL_TEXT.disabledUser(item.name)
: soft ? PRINCIPAL_TEXT.unknownOidc(item.name)
: item.kind === 'group' ? PRINCIPAL_TEXT.unknownGroup(item.name) : PRINCIPAL_TEXT.unknownUser(item.name),
})
@@ -134,7 +145,8 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
? (!pool ? [] : kind === 'user' ? pool.users.map((u) => ({ value: u.username, label: u.display_name }))
: pool.groups.map((g) => ({ value: g.name, label: g.label })))
: !known ? [] : kind === 'user'
? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name }))
? known.users.filter((u) => !u.disabled && (allowService || !u.service))
.map((u) => ({ value: u.username, label: u.display_name }))
: known.groups.map((g) => ({ value: g.name, label: g.label }))
/** 候选池要先输入开头才列(应用授权给了所有人)、列不全(超过上限) */
const poolHint = asking && pool
@@ -146,7 +158,7 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
{value.length > 0 && (
<ul className="mb-1.5 flex flex-wrap gap-1" aria-label={label}>
{value.map((p) => {
const problem = problemOf(p, known)
const problem = problemOf(p, known, allowService)
return (
<li key={`${p.kind}:${p.name}`} className="chip flex items-center gap-1" data-principal={p.kind}
data-principal-problem={problem ?? undefined}
@@ -156,7 +168,8 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
{p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : shown.current.get(p.name) ?? p.name}
</span>
<span className="sr-only">({PRINCIPAL_TEXT[p.kind]})</span>
{problem && <span className="text-2xs">· {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}</span>}
{problem && <span className="text-2xs">· {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag
: problem === 'service' ? PRINCIPAL_TEXT.serviceTag : PRINCIPAL_TEXT.missingTag}</span>}
<button type="button" className="-mr-0.5 rounded text-faint hover:text-fg" aria-label={PRINCIPAL_TEXT.remove(p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : p.name)}
onClick={() => remove(p)}>
<X size={11} aria-hidden />
@@ -0,0 +1,69 @@
/**
* 「最近的请求」:外部系统发来的请求逐条列出——访问密钥的请求记录(设置 → 访问密钥)和钩子的收件记录(应用编辑 → 钩子)
* 共用这一个组件(docs/design/next-round/04-对外集成与触达.md A6「一种说法」)。外部系统说「我发过了」,这里查得到收到没有、
* 回了什么、为什么被拒。
*
* 只管画:每一行时间、结果(成功 / 被拒 / 处理中)、是什么(接口或幂等键)、原因或补充、涉及的运行或待办。取数和把服务端的
* 形状换成这里的行在调用方做(界面层不放逻辑:结果的判断服务端已经给了,这里只照着上色)。
*/
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ErrorState, Skeleton } from './ui'
import { formatDateTime } from '../lib/format'
import { RECENT_REQUESTS_TEXT as T } from '../lib/termsApp'
export interface RecentRequestRow {
key: string
at: string | null
/** ok 成功;bad 被拒;pending 处理中 */
tone: 'ok' | 'bad' | 'pending'
/** 结果那一格的字(「成功 200」「被拒」……) */
result: string
/** 是什么:接口(方法 + 路由模板)或幂等键 */
what: string
/** 原因、来源地址这类补充,没有为空 */
detail?: string | null
/** 涉及的运行或待办:label 是「运行」「实例」「待办」,to 给了就是能点的链接 */
ref?: { label: string; id: string; to?: string | null } | null
}
const TONE: Record<RecentRequestRow['tone'], string> = { ok: 'var(--ok)', bad: 'var(--err)', pending: 'var(--st-waiting)' }
export function RecentRequests({ rows, error, onRetry, summary, empty, attr }: {
rows: RecentRequestRow[] | null
error?: unknown
onRetry?: () => void
/** 列表上面的一句(近 1 小时验签失败几次、保留几天) */
summary?: ReactNode
/** 一条都没有时写什么 */
empty?: string
/** 给检查脚本认的 data- 属性 */
attr?: Record<string, string>
}) {
return (
<div className="space-y-1.5" data-recent-requests="" {...attr}>
{summary && <p className="text-2xs text-faint" data-recent-requests-summary="">{summary}</p>}
{error ? <ErrorState error={error} onRetry={onRetry} compact /> : !rows ? <Skeleton rows={3} height={20} /> : !rows.length ? (
<p className="text-2xs text-faint" data-recent-requests-empty="">{empty ?? T.empty}</p>
) : (
<ol className="divide-y rounded-lg border text-2xs" aria-label={T.label}>
{rows.map((r) => (
<li key={r.key} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 px-2.5 py-1.5" data-recent-request={r.tone}>
<time className="tnum w-32 shrink-0 text-faint">{r.at ? formatDateTime(r.at) : '—'}</time>
<span className="chip shrink-0" style={{ color: TONE[r.tone], borderColor: TONE[r.tone] }}>{r.result}</span>
<span className="mono min-w-0 flex-1 truncate" title={r.what}>{r.what}</span>
{r.ref && (
<span className="shrink-0 text-faint">
{r.ref.label}{' '}
{r.ref.to ? <Link className="mono underline-offset-2 hover:text-fg hover:underline" to={r.ref.to}>{r.ref.id.slice(0, 8)}</Link>
: <span className="mono" title={r.ref.id}>{r.ref.id.slice(0, 8)}</span>}
</span>
)}
{r.detail && <span className="basis-full text-dim" data-recent-request-detail="">{r.detail}</span>}
</li>
))}
</ol>
)}
</div>
)
}
+3 -1
View File
@@ -5409,9 +5409,11 @@ export const IDENTITY_TEXT = {
removeGroup: (name: string) => `删除组「${name}」?`,
removeGroupBody: '应用里绑定到这个组的岗位和授权将失效。',
offNote: '当前关闭登录:用户和组仅在开启登录后生效',
/** 服务账号(只持有访问密钥):角色、密码不在这里改 */
serviceManaged: '服务账号:没有密码、不能登录,在「设置 → 访问密钥」里管理',
adminOnly: '只有管理员可以管理身份',
audit: '审计',
auditHint: '管理类操作的留痕:登录方式、角色、组、授权、应用发布、端点、能力治理、版本退役',
auditHint: '管理类操作的留痕:登录方式、角色、组、授权、应用发布、端点、能力治理、版本退役、访问密钥',
auditEmpty: '还没有审计记录',
auditVerified: '已登录',
auditUnverified: '自报署名',
+123
View File
@@ -368,6 +368,9 @@ export const PRINCIPAL_TEXT = {
/** 已选的那一项不在名录里、或已停用:标在标签上 */
missingTag: '名录里没有',
disabledTag: '已停用',
/** 服务账号能拿应用授权,不能被派待办:岗位绑定、改派里不收 */
serviceUser: (name: string) => `「${name}」是服务账号,不能被派待办:请选人或组`,
serviceTag: '服务账号,不能被派待办',
/** 使用者选人:候选按这件事能选的人给(服务端 auth/directory 的候选池,05-已知遗留 (b)) */
notCandidate: (name: string) => `「${name}」不在可选的人里,请从候选中选择`,
typeToSearch: '能选的人较多:输入名字的开头查找',
@@ -679,3 +682,123 @@ export const REVIEW_TASK_TEXT = {
rejectNote: '退回的原因(选填)',
rejected: '已退回',
}
// ---------------------------------------------------------------------------
// 访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7;叫法定在总方案 X10:访问密钥,不叫令牌、token、
// 凭据——「token」专指模型用量,「凭据」专指出站端点的凭据)
// ---------------------------------------------------------------------------
export const ACCESS_KEY_TEXT = {
tab: '访问密钥',
hint: '外部系统和脚本用访问密钥调接口。访问密钥按使用者的权限算:只看得到授权给拥有者(或所在的组、所有人)的应用,看到的数据一律遮罩,管理员的密钥也一样',
loginOff: '未开启登录,访问密钥不生效:关登录时所有请求都等同管理员,带着密钥也不会按密钥的权限收窄。开启登录后才能建密钥',
viewKeys: '密钥',
viewReference: '接口说明',
personalSwitch: '允许个人访问密钥',
personalSwitchHint: '打开后每个人可以在这里给自己建访问密钥(写脚本拉数用)。关掉时建不了,已有的个人访问密钥也一并不生效。系统间集成请用服务账号的密钥',
personalSwitchOn: '已允许个人访问密钥',
personalSwitchOff: '已关闭个人访问密钥',
personalOff: '管理员还没有打开个人访问密钥。系统间集成请让管理员建服务账号的密钥',
serviceAccounts: '服务账号',
serviceHint: '给外部系统用的身份:用户名以 svc- 开头,没有密码、不能登录,角色固定是使用者。在应用的「岗位与授权」里给它授权;它不能被派待办。停用之后它的访问密钥全部失效',
serviceEmpty: '还没有服务账号',
createService: '新建服务账号',
serviceUsername: '用户名',
serviceUsernameHint: '以 svc- 开头,只用字母、数字、点、下划线和短横线,建好后不能修改',
serviceDisplay: '显示名',
serviceKeys: (n: number) => (n ? `${n} 把在用的密钥` : '没有在用的密钥'),
serviceCreated: (name: string) => `已建服务账号「${name}」`,
disabled: '已停用',
disable: '停用',
enable: '启用',
disableTitle: (name: string) => `停用服务账号「${name}」?`,
disableBody: '停用后它的访问密钥全部失效,用这些密钥的外部系统会收到 401。重新启用后没到期、没撤销的密钥就又能用。',
myKeys: '我的访问密钥',
allKeys: '全部访问密钥',
keysEmpty: '还没有访问密钥',
create: '新建访问密钥',
owner: '拥有者',
ownerMe: '我自己(个人访问密钥)',
ownerService: (name: string) => `服务账号 ${name}`,
noOwner: '还不能建:先打开个人访问密钥,或者建一个服务账号',
name: '名称',
nameHint: '写清是给哪个系统、哪个脚本用的',
scopes: '权限',
scopesHint: '新建时缺省只勾读的。发起和刷新时请求头里必须带幂等键;回复待办只给个人访问密钥',
scopeLabel: {
'apps.read': '读应用:列表、页面、视图数据、实例',
'apps.run': '发起入口、刷新视图',
'tasks.read': '读待办',
'tasks.respond': '回复待办:只限审批、补充输入、编辑稿件',
'reports.read': '读报告、证据和工件',
} as Record<string, string>,
serviceNoRespond: '服务账号不能被派待办,它的密钥不能回复待办',
apps: '限定应用',
appsHint: '不选就是拥有者有授权的全部应用。个人应用一律不能用访问密钥读',
appsAll: '不限应用',
appsCount: (n: number) => `限定 ${n} 个应用`,
appsNone: '没有可选的应用',
expires: '有效期(天)',
expiresHint: (max: number) => `必填,最长 ${max} 天。到期前请轮换`,
state: { active: '在用', revoked: '已撤销', expired: '已过期', owner_disabled: '拥有者已停用' } as Record<string, string>,
personalTag: '个人',
serviceTag: '服务账号',
expiresAt: (t: string) => `到期 ${t}`,
lastUsed: (t: string) => `最近使用 ${t}`,
neverUsed: '还没用过',
rotatedFrom: '由轮换签发',
revoke: '撤销',
revokeTitle: (name: string) => `撤销访问密钥「${name}」?`,
revokeBody: '撤销后立刻失效,用它的外部系统会收到 401,不能恢复。',
revoked: '已撤销',
rotate: '轮换',
rotateTitle: (name: string) => `轮换访问密钥「${name}」?`,
rotateBody: '会签发一把新的密钥(权限、限定的应用、有效期长短照旧),旧的在 24 小时后到期。请在这段时间里把外部系统换成新密钥。',
rotated: '已签发新密钥,旧密钥 24 小时后到期',
created: '访问密钥已建好',
onceTitle: (name: string) => `「${name}」的访问密钥`,
onceBody: '密钥只显示这一次:关掉之后谁都看不到(服务端只存它的哈希)。请现在复制,交给要用它的系统。',
onceUsage: '调接口时放在请求头里',
done: '我已保存',
requests: '最近的请求',
hideRequests: '收起',
requestsHint: (days: number) => `最近 100 条,保留 ${days} 天`,
reference: '接口说明',
referenceHint: '访问密钥能调的接口只有下面这些:这份说明和服务端放行的接口出自同一张表',
refAuth: '认证方式',
refBase: '对外访问地址',
refBaseNone: '还没有设置对外访问地址(设置 → 通用)',
refRate: (n: number) => `每把密钥每分钟最多 ${n} 次请求,超了回 429,按响应头 Retry-After 的秒数等一会再试`,
refIdempotent: (header: string) => `发起和刷新必须在请求头里带 ${header}:同一件事重发时带同一个键,就不会多发起一次`,
refCommon: '每个接口都可能回的错误码',
refColMethod: '方法',
refColPath: '路径',
refColScope: '权限',
refColSummary: '说明',
refColErrors: '可能的错误码',
refNeedsKey: '要幂等键',
refImplicit: '不用申请',
}
/** 「最近的请求」(访问密钥的请求记录、钩子的收件记录共用一个组件,04 细案 A6) */
export const RECENT_REQUESTS_TEXT = {
label: '最近的请求',
empty: '还没有请求',
run: '运行',
instance: '实例',
task: '待办',
status: (code: number) => (code >= 200 && code < 300 ? `成功 ${code}` : `被拒 ${code}`),
}
/** 钩子的收件记录(应用编辑 → 钩子) */
export const HOOK_RECEIPT_TEXT = {
show: '收件记录',
hide: '收起收件记录',
title: (name: string) => `钩子「${name}」最近收到的请求`,
status: { processing: '处理中', accepted: '已接受', rejected: '被拒' } as Record<string, string>,
empty: '还没有收到过验过签名的请求',
idem: '幂等键',
failures: (n: number) => (n
? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数`
: '近 1 小时没有验签失败的请求'),
}
+12 -3
View File
@@ -37,6 +37,8 @@ import { GeneralTab } from './settings/GeneralTab'
import { IdentityTab } from './settings/IdentityTab'
import { NotifyTab } from './settings/NotifyTab'
import { AuditTab } from './settings/AuditTab'
import { AccessKeysTab } from './settings/AccessKeysTab'
import { ACCESS_KEY_TEXT } from '../lib/termsApp'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
// 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去
@@ -49,12 +51,14 @@ const TABS = [
// 应用平台(A7):登录方式和人、通知渠道、管理类操作的留痕
{ key: 'identity', label: IDENTITY_TEXT.tab },
{ key: 'notify', label: NOTIFY_TEXT.tab },
// 访问密钥(下一轮第 2 波):管理员管服务账号和全部密钥,别人管自己的个人访问密钥
{ key: 'keys', label: ACCESS_KEY_TEXT.tab },
{ key: 'audit', label: IDENTITY_TEXT.audit },
]
/** 只给管理员的页签(§3.12):登录方式和人、审计 */
const ADMIN_TABS = new Set(['identity', 'audit'])
/** 开了登录的使用者只剩个人的:主题、通知渠道(个人) */
/** 开了登录的使用者只剩个人的:主题、通知渠道(个人);管理员开了个人访问密钥时另有自己的访问密钥 */
const USER_TABS = new Set(['prefs', 'notify'])
export function SettingsPage() {
@@ -63,7 +67,11 @@ export function SettingsPage() {
const builder = atLeast(me, 'builder')
const admin = atLeast(me, 'admin')
// 按角色收起:关登录时所有人等同管理员,全部页签照旧
const tabs = TABS.filter((t) => (builder ? admin || !ADMIN_TABS.has(t.key) : USER_TABS.has(t.key)))
// 访问密钥:管理员(关登录时人人等同管理员,页里照实说不生效)一直有;别人要管理员开了个人访问密钥才有——关着时建不了,
// 已有的个人密钥也不生效,给一页空的没有用
const keys = admin || !!me?.personal_keys
const tabs = TABS.filter((t) => (t.key === 'keys' ? keys
: builder ? admin || !ADMIN_TABS.has(t.key) : USER_TABS.has(t.key)))
// datasources 也算认得:否则 useTabRoute 会先把地址纠正回 providers,和下面的跳转打架
const [tab, setTab] = useTabRoute([...tabs.map((t) => t.key), ...(builder ? ['datasources'] : [])], builder ? 'providers' : 'prefs')
@@ -78,7 +86,7 @@ export function SettingsPage() {
return (
<div className="flex h-full flex-col">
<PageHeader icon={<SettingsIcon size={13} />} title="设置"
subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份、通知和访问密钥' : SETTINGS_ROLE_TEXT.subtitleUser} />
<Tabs tabs={tabs} active={tab} onChange={change} label="设置" idPrefix="settings" />
<TabPanel idPrefix="settings" tabKey={tab} className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl p-4">
@@ -88,6 +96,7 @@ export function SettingsPage() {
{tab === 'system' && <SystemTab />}
{tab === 'identity' && <IdentityTab />}
{tab === 'notify' && <NotifyTab />}
{tab === 'keys' && <AccessKeysTab />}
{tab === 'audit' && <AuditTab />}
</div>
</TabPanel>
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { KeyRound, Plus } from 'lucide-react'
import { api } from '../../../api/client'
import type { AppHookRow } from '../../../typesApp'
import type { AppHookRow, HookReceipts } from '../../../typesApp'
import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests'
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp'
import {
CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../../components/ui'
@@ -115,7 +117,8 @@ function Grants() {
<div className="text-xs font-medium" data-app-grant-title="">{GRANT_ROLE_LABEL[role]}</div>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{GRANT_ROLE_HINT[role]}</p>
</div>
<PrincipalPicker label={GRANT_ROLE_LABEL[role]} allowEveryone={role !== 'manage'}
{/* 服务账号能拿授权(外部系统按授权读应用);岗位绑定那边不能选它 */}
<PrincipalPicker label={GRANT_ROLE_LABEL[role]} allowEveryone={role !== 'manage'} allowService
value={items.filter((g) => g.role === role).map((g) => ({ kind: g.principal_kind, name: g.principal }))}
onChange={(v) => {
setItems((cur) => [...(cur ?? []).filter((g) => g.role !== role),
@@ -142,6 +145,8 @@ export function HooksSection() {
const [loadError, setLoadError] = useState<unknown>(null)
const [busy, setBusy] = useState<string | null>(null)
const [shown, setShown] = useState<{ name: string; secret: string; path: string | null } | null>(null)
/** 展开了哪一个钩子的收件记录 */
const [receiptsOf, setReceiptsOf] = useState<string | null>(null)
const load = useCallback(() => {
api.apps.hooks(appId).then((list) => { setRows(list); setLoadError(null) }).catch(setLoadError)
}, [appId])
@@ -200,8 +205,12 @@ export function HooksSection() {
}
const status = (name: string, declared: boolean) => (
<HookStatus name={name} state={stateOf(name)} declared={declared} busy={busy === name} loaded={!!rows}
onRotate={() => void rotate(name)} onToggle={(v) => void toggle(name, v)} onRemove={() => void removeKey(name)} />
<>
<HookStatus name={name} state={stateOf(name)} declared={declared} busy={busy === name} loaded={!!rows}
receipts={receiptsOf === name} onReceipts={() => setReceiptsOf(receiptsOf === name ? null : name)}
onRotate={() => void rotate(name)} onToggle={(v) => void toggle(name, v)} onRemove={() => void removeKey(name)} />
{receiptsOf === name && stateOf(name)?.has_secret && <HookReceiptsPanel appId={appId} name={name} />}
</>
)
return (
@@ -263,13 +272,15 @@ export function HooksSection() {
)
}
/** 一行钩子的服务端状态:发布版里有没有、生成过密钥没有、启用;生成 / 轮换、删除密钥 */
function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, onRemove }: {
/** 一行钩子的服务端状态:发布版里有没有、生成过密钥没有、启用;生成 / 轮换、删除密钥;展开收件记录 */
function HookStatus({ name, state, declared, busy, loaded, receipts, onReceipts, onRotate, onToggle, onRemove }: {
name: string
state: AppHookRow | undefined
declared: boolean
busy: boolean
loaded: boolean
receipts: boolean
onReceipts: () => void
onRotate: () => void
onToggle: (enabled: boolean) => void
onRemove: () => void
@@ -287,6 +298,12 @@ function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, o
: state?.in_published ? <span className="text-faint">{T.hookPublished}</span>
: <span style={{ color: 'var(--st-waiting)' }}>{T.hookNotPublished}</span>}
<span className="flex-1" />
{has && (
<button type="button" className="btn btn-xs btn-ghost" onClick={onReceipts} aria-expanded={receipts}
data-app-hook-receipts-toggle={name}>
{receipts ? HOOK_RECEIPT_TEXT.hide : HOOK_RECEIPT_TEXT.show}
</button>
)}
{has && (
<label className="flex items-center gap-1" title={T.hookEnabledHint}>
<input type="checkbox" className="accent-[var(--accent)]" checked={state!.enabled} disabled={busy}
@@ -308,6 +325,35 @@ function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, o
)
}
/**
* 钩子的收件记录(04 细案 A6):最近 100 条验过签名的请求(状态、原因、发起的实例或解决的待办)和近 1 小时验签失败的次数。
* 和访问密钥的「最近的请求」同一个组件
*/
function HookReceiptsPanel({ appId, name }: { appId: string; name: string }) {
const [data, setData] = useState<HookReceipts | null>(null)
const [error, setError] = useState<unknown>(null)
const load = useCallback(() => {
setError(null)
api.apps.hookReceipts(appId, name).then(setData).catch(setError)
}, [appId, name])
useEffect(load, [load])
const rows: RecentRequestRow[] | null = data ? data.items.map((r, i) => ({
key: `${r.idem_key}-${i}`, at: r.received_at,
tone: r.status === 'accepted' ? 'ok' : r.status === 'rejected' ? 'bad' : 'pending',
result: HOOK_RECEIPT_TEXT.status[r.status] ?? r.status, what: `${HOOK_RECEIPT_TEXT.idem} ${r.idem_key}`, detail: r.detail,
ref: r.run_id ? { label: RECENT_REQUESTS_TEXT.instance, id: r.run_id,
to: `/apps/${encodeURIComponent(appId)}/instances/${encodeURIComponent(r.run_id)}` }
: r.task_id ? { label: RECENT_REQUESTS_TEXT.task, id: r.task_id } : null,
})) : null
return (
<div className="space-y-1 rounded-lg border bg-bg px-2.5 py-2" data-app-hook-receipts={name}>
<div className="text-2xs font-medium">{HOOK_RECEIPT_TEXT.title(name)}</div>
<RecentRequests rows={rows} error={error} onRetry={load} empty={HOOK_RECEIPT_TEXT.empty}
summary={data ? HOOK_RECEIPT_TEXT.failures(data.signature_failures) : undefined} />
</div>
)
}
/** 刚生成、轮换的密钥:只显示这一次,附复制按钮和入站地址;关掉、离开这一节就没有了 */
function SecretOnce({ shown, onDismiss }: { shown: { name: string; secret: string; path: string | null }; onDismiss: () => void }) {
const url = shown.path ? `${window.location.origin}${shown.path}` : null
@@ -0,0 +1,505 @@
import { useCallback, useEffect, useState } from 'react'
import { BookOpen, KeyRound, Plus, Users } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import {
CopyButton, EmptyState, ErrorState, Field, Modal, Notice, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../components/ui'
import { RecentRequests, type RecentRequestRow } from '../../components/RecentRequests'
import { formatDateTime } from '../../lib/format'
import { ACCESS_KEY_TEXT as T, RECENT_REQUESTS_TEXT } from '../../lib/termsApp'
import type {
AccessKeyIssued, AccessKeyReference, AccessKeyRequestRow, AccessKeyRow, AccessKeyScope, AccessKeySettings,
AppSummary, ServiceAccountRow,
} from '../../typesApp'
/*
* 设置 → 访问密钥(docs/design/next-round/04-对外集成与触达.md A1、A6、A7)。
*
* 管理员:个人访问密钥的开关、服务账号(建、停用)、全部密钥;别人:自己的个人访问密钥,管理员打开开关之后才能建。
* 密钥明文只在建、轮换的回包里有,这里显示一次、附复制按钮,关掉就没有了(服务端只存哈希)。撤销、轮换写审计;每把密钥
* 能展开「最近的请求」(和钩子的收件记录同一个组件)。「接口说明」从服务端门禁那张表生成,和真实放行的接口出自一处。
*
* 关登录时访问密钥不生效:照实写一句,不给建密钥的入口(建了也用不了)。权限、到期、能不能轮换都由服务端判,这里照它给的
* 状态显示(界面层不放逻辑)。
*/
type View = 'keys' | 'reference'
export function AccessKeysTab() {
const [settings, setSettings] = useState<AccessKeySettings | null>(null)
const [error, setError] = useState<unknown>(null)
const [view, setView] = useState<View>('keys')
const load = useCallback(() => {
setError(null)
api.accessKeys.settings().then(setSettings).catch(setError)
}, [])
useEffect(load, [load])
if (error) return <ErrorState error={error} onRetry={load} />
if (!settings) return <Skeleton rows={4} height={40} gap={10} />
return (
<div className="space-y-6" data-access-keys={settings.admin ? 'admin' : 'self'}>
<p className="text-xs leading-relaxed text-dim">{T.hint}</p>
{!settings.login_on && <Notice tone="warn" attr={{ 'data-access-keys-login-off': '' }}>{T.loginOff}</Notice>}
{/* 页内切换(不是又一层页签):密钥、接口说明 */}
<div className="flex gap-1" role="group" aria-label={T.tab}>
{([['keys', T.viewKeys, KeyRound], ['reference', T.viewReference, BookOpen]] as const).map(([key, label, Icon]) => (
<button key={key} type="button" aria-pressed={view === key} data-access-keys-view={key}
className={clsx('btn btn-sm', view === key && 'btn-primary')} onClick={() => setView(key)}>
<Icon size={11} aria-hidden /> {label}
</button>
))}
</div>
{view === 'reference' ? <Reference /> : (
<>
{settings.admin && settings.login_on && <PersonalSwitch settings={settings} onChange={setSettings} />}
{settings.admin && settings.login_on && <ServiceAccounts />}
{settings.login_on && <Keys settings={settings} />}
</>
)}
</div>
)
}
// ---------------------------------------------------------------- 个人访问密钥的开关(管理员)
function PersonalSwitch({ settings, onChange }: { settings: AccessKeySettings; onChange: (s: AccessKeySettings) => void }) {
const [busy, setBusy] = useState(false)
const flip = async (on: boolean) => {
setBusy(true)
try {
onChange(await api.accessKeys.setPersonalKeys(on))
toast.ok(on ? T.personalSwitchOn : T.personalSwitchOff)
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<label className="flex items-start gap-2 text-xs" data-access-keys-personal-switch={settings.personal_keys ? 'on' : 'off'}>
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" checked={settings.personal_keys} disabled={busy}
onChange={(e) => void flip(e.target.checked)} />
<span>{T.personalSwitch}<span className="mt-0.5 block text-2xs text-faint">{T.personalSwitchHint}</span></span>
</label>
)
}
// ---------------------------------------------------------------- 服务账号(管理员)
function ServiceAccounts() {
const [rows, setRows] = useState<ServiceAccountRow[] | null>(null)
const [error, setError] = useState<unknown>(null)
const [creating, setCreating] = useState(false)
const load = useCallback(() => {
api.accessKeys.serviceAccounts().then((items) => { setRows(items); setError(null) }).catch(setError)
}, [])
useEffect(load, [load])
const toggle = async (row: ServiceAccountRow) => {
if (!row.disabled && !(await confirmDialog({ title: T.disableTitle(row.username), body: T.disableBody, danger: true,
confirmLabel: T.disable }))) return
try {
await api.accessKeys.updateServiceAccount(row.id, { disabled: !row.disabled })
load()
} catch (e) {
toast.error(e)
}
}
return (
<section data-service-accounts="">
<SectionBar title={T.serviceAccounts} hint={T.serviceHint}>
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)} data-service-account-create="">
<Plus size={12} aria-hidden /> {T.createService}
</button>
</SectionBar>
{error ? <ErrorState error={error} onRetry={load} compact /> : !rows ? <Skeleton rows={2} height={36} /> : !rows.length ? (
<EmptyState icon={<Users size={20} />} title={T.serviceEmpty} />
) : (
<div className="divide-y rounded-lg border">
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-2 px-3 py-2 text-xs" data-service-account={r.username}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="mono font-medium">{r.username}</span>
{r.display_name !== r.username && <span className="text-dim">{r.display_name}</span>}
{r.disabled && <span className="chip text-faint">{T.disabled}</span>}
</div>
<div className="text-2xs text-faint">{T.serviceKeys(r.keys)}</div>
</div>
<button type="button" className="btn btn-xs btn-ghost" onClick={() => void toggle(r)}
data-service-account-toggle={r.disabled ? 'enable' : 'disable'}>
{r.disabled ? T.enable : T.disable}
</button>
</div>
))}
</div>
)}
{creating && <ServiceCreator onClose={() => setCreating(false)} onSaved={() => { setCreating(false); load() }} />}
</section>
)
}
function ServiceCreator({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
const [username, setUsername] = useState('svc-')
const [display, setDisplay] = useState('')
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
try {
const row = await api.accessKeys.createServiceAccount({ username: username.trim(), display_name: display.trim() || undefined })
toast.ok(T.serviceCreated(row.username))
onSaved()
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<Modal open onClose={onClose} title={T.createService} width={480}
footer={<>
<button type="button" className="btn" onClick={onClose}>取消</button>
<button type="button" className="btn btn-primary" disabled={busy || username.trim().length < 5}
onClick={() => void save()} data-service-account-save="">
{busy ? <Spinner size={11} /> : null} {T.createService}
</button>
</>}>
<div className="grid grid-cols-2 gap-3" data-service-account-form="">
<Field label={T.serviceUsername} hint={T.serviceUsernameHint} required>
{(p) => <input {...p} className="field mono" value={username} autoComplete="off"
onChange={(e) => setUsername(e.target.value)} data-service-account-username="" />}
</Field>
<Field label={T.serviceDisplay}>
{(p) => <input {...p} className="field" value={display} onChange={(e) => setDisplay(e.target.value)} />}
</Field>
</div>
</Modal>
)
}
// ---------------------------------------------------------------- 密钥
function Keys({ settings }: { settings: AccessKeySettings }) {
const [rows, setRows] = useState<AccessKeyRow[] | null>(null)
const [error, setError] = useState<unknown>(null)
const [creating, setCreating] = useState(false)
const [issued, setIssued] = useState<AccessKeyIssued | null>(null)
const [open, setOpen] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const load = useCallback(() => {
api.accessKeys.list().then((items) => { setRows(items); setError(null) }).catch(setError)
}, [])
useEffect(load, [load])
const canCreate = settings.admin || settings.can_create_personal
const revoke = async (row: AccessKeyRow) => {
if (!(await confirmDialog({ title: T.revokeTitle(row.name), body: T.revokeBody, danger: true, confirmLabel: T.revoke }))) return
setBusy(row.id)
try {
await api.accessKeys.revoke(row.id)
toast.ok(T.revoked)
load()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
const rotate = async (row: AccessKeyRow) => {
if (!(await confirmDialog({ title: T.rotateTitle(row.name), body: T.rotateBody, confirmLabel: T.rotate }))) return
setBusy(row.id)
try {
setIssued(await api.accessKeys.rotate(row.id))
toast.ok(T.rotated)
load()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
return (
<section data-access-key-list="">
<SectionBar title={settings.admin ? T.allKeys : T.myKeys}>
{canCreate && (
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)} data-access-key-create="">
<Plus size={12} aria-hidden /> {T.create}
</button>
)}
</SectionBar>
{!settings.admin && !settings.personal_keys && (
<Notice tone="info" className="mb-2" attr={{ 'data-access-keys-personal-off': '' }}>{T.personalOff}</Notice>
)}
{issued && <KeyOnce issued={issued} onDone={() => setIssued(null)} />}
{error ? <ErrorState error={error} onRetry={load} compact /> : !rows ? <Skeleton rows={3} height={44} /> : !rows.length ? (
<EmptyState icon={<KeyRound size={20} />} title={T.keysEmpty} />
) : (
<div className="divide-y rounded-lg border">
{rows.map((r) => (
<div key={r.id} className="space-y-1.5 px-3 py-2 text-xs" data-access-key={r.name} data-access-key-state={r.state}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{r.name}</span>
<code className="mono text-2xs text-faint">{r.prefix}…</code>
<span className="chip">{r.personal ? T.personalTag : T.serviceTag}</span>
<span className="chip" style={r.state === 'active' ? { color: 'var(--ok)', borderColor: 'var(--ok)' }
: { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' }}>{T.state[r.state] ?? r.state}</span>
{r.rotated_from && <span className="text-2xs text-faint">{T.rotatedFrom}</span>}
<span className="flex-1" />
<button type="button" className="btn btn-xs btn-ghost" onClick={() => setOpen(open === r.id ? null : r.id)}
aria-expanded={open === r.id} data-access-key-requests-toggle={r.name}>
{open === r.id ? T.hideRequests : T.requests}
</button>
{r.state === 'active' && (
<>
<button type="button" className="btn btn-xs" disabled={busy === r.id} onClick={() => void rotate(r)}
data-access-key-rotate={r.name}>
{busy === r.id ? <Spinner size={10} /> : null} {T.rotate}
</button>
<button type="button" className="btn btn-xs btn-ghost" disabled={busy === r.id} onClick={() => void revoke(r)}
data-access-key-revoke={r.name}>
{T.revoke}
</button>
</>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-faint">
{settings.admin && <span>{T.owner}:<span className="mono">{r.owner.username ?? '—'}</span></span>}
<span>{T.expiresAt(formatDateTime(r.expires_at))}</span>
<span>{r.last_used_at ? T.lastUsed(formatDateTime(r.last_used_at)) : T.neverUsed}</span>
<span>{r.app_ids ? T.appsCount(r.app_ids.length) : T.appsAll}</span>
</div>
<div className="flex flex-wrap gap-1" data-access-key-scopes="">
{r.scopes.map((s) => <span key={s} className="chip text-2xs" title={T.scopeLabel[s]}>{s}</span>)}
</div>
{open === r.id && <KeyRequests id={r.id} />}
</div>
))}
</div>
)}
{creating && (
<KeyCreator settings={settings} onClose={() => setCreating(false)}
onIssued={(k) => { setCreating(false); setIssued(k); toast.ok(T.created); load() }} />
)}
</section>
)
}
/** 刚建好、刚轮换的密钥:只显示这一次 */
function KeyOnce({ issued, onDone }: { issued: AccessKeyIssued; onDone: () => void }) {
return (
<div className="mb-3 space-y-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--st-waiting)' }} role="status"
data-access-key-once={issued.name}>
<div className="flex items-center gap-1.5 text-xs font-semibold">
<KeyRound size={12} aria-hidden /> {T.onceTitle(issued.name)}
</div>
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>{T.onceBody}</p>
<div className="flex items-center gap-2">
<code className="mono min-w-0 flex-1 break-all rounded border bg-bg px-2 py-1 text-2xs" data-access-key-secret="">
{issued.key}
</code>
<CopyButton text={issued.key} />
</div>
<div className="text-2xs text-faint">
{T.onceUsage}:<code className="mono">Authorization: Bearer {issued.prefix}…</code>
</div>
<div className="flex justify-end">
<button type="button" className="btn btn-sm" onClick={onDone} data-access-key-once-done="">{T.done}</button>
</div>
</div>
)
}
function KeyCreator({ settings, onClose, onIssued }: {
settings: AccessKeySettings
onClose: () => void
onIssued: (k: AccessKeyIssued) => void
}) {
const [services, setServices] = useState<ServiceAccountRow[]>([])
const [apps, setApps] = useState<AppSummary[] | null>(null)
// 拥有者:'' 是我自己(个人访问密钥),否则是服务账号 id
const [owner, setOwner] = useState<string | null>(settings.can_create_personal ? '' : null)
const [name, setName] = useState('')
const [scopes, setScopes] = useState<AccessKeyScope[]>([...settings.read_scopes])
const [limited, setLimited] = useState<string[]>([])
const [days, setDays] = useState(settings.expires_default_days)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (settings.admin) {
api.accessKeys.serviceAccounts().then((rows) => {
const usable = rows.filter((r) => !r.disabled)
setServices(usable)
setOwner((cur) => (cur === null && usable.length ? usable[0].id : cur))
}).catch(() => setServices([]))
}
api.apps.list().then(({ items }) => setApps(items.filter((a) => (a.scope ?? 'shared') !== 'personal' && a.status !== 'archived')))
.catch(() => setApps([]))
}, [settings.admin])
const service = owner !== null && owner !== ''
const personalOnly = new Set(settings.personal_scopes)
const picked = scopes.filter((s) => !(service && personalOnly.has(s)))
const tick = (s: AccessKeyScope, on: boolean) => setScopes((cur) => (on ? [...cur, s] : cur.filter((x) => x !== s)))
const save = async () => {
if (owner === null) return
setBusy(true)
try {
onIssued(await api.accessKeys.create({
name: name.trim(), scopes: picked, app_ids: limited.length ? limited : null, expires_in_days: days,
...(service ? { service_account_id: owner } : {}),
}))
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
const daysOk = Number.isInteger(days) && days >= 1 && days <= settings.expires_max_days
return (
<Modal open onClose={onClose} title={T.create} width={600}
footer={<>
<button type="button" className="btn" onClick={onClose}>取消</button>
<button type="button" className="btn btn-primary" data-access-key-save=""
disabled={busy || owner === null || !name.trim() || !picked.length || !daysOk} onClick={() => void save()}>
{busy ? <Spinner size={11} /> : null} {T.create}
</button>
</>}>
<div className="space-y-3" data-access-key-form="">
{owner === null && !services.length && <Notice tone="warn">{T.noOwner}</Notice>}
{settings.admin && (
<Field label={T.owner}>
{(p) => (
<select {...p} className="field" value={owner ?? ''} onChange={(e) => setOwner(e.target.value)} data-access-key-owner="">
{settings.can_create_personal && <option value="">{T.ownerMe}</option>}
{services.map((s) => <option key={s.id} value={s.id}>{T.ownerService(s.username)}</option>)}
</select>
)}
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={T.name} hint={T.nameHint} required>
{(p) => <input {...p} className="field" value={name} maxLength={100} onChange={(e) => setName(e.target.value)}
data-access-key-name="" />}
</Field>
<Field label={T.expires} hint={T.expiresHint(settings.expires_max_days)} required
error={daysOk ? undefined : T.expiresHint(settings.expires_max_days)}>
{(p) => <input {...p} className="field tnum" type="number" min={1} max={settings.expires_max_days} value={days}
onChange={(e) => setDays(Number(e.target.value))} data-access-key-days="" />}
</Field>
</div>
<fieldset className="space-y-1 rounded-lg border px-3 py-2" data-access-key-scope-list="">
<legend className="px-1 text-xs font-medium">{T.scopes}</legend>
<p className="text-2xs text-faint">{T.scopesHint}</p>
{settings.scopes.map((s) => {
const blocked = service && personalOnly.has(s)
return (
<label key={s} className={clsx('flex items-start gap-2 text-xs', blocked && 'opacity-60')} data-access-key-scope={s}>
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" checked={!blocked && scopes.includes(s)}
disabled={blocked} onChange={(e) => tick(s, e.target.checked)} />
<span>
<code className="mono">{s}</code> {T.scopeLabel[s]}
{blocked && <span className="block text-2xs text-faint">{T.serviceNoRespond}</span>}
</span>
</label>
)
})}
</fieldset>
<fieldset className="space-y-1 rounded-lg border px-3 py-2" data-access-key-app-list="">
<legend className="px-1 text-xs font-medium">{T.apps}</legend>
<p className="text-2xs text-faint">{T.appsHint}</p>
{!apps ? <Skeleton rows={2} height={16} /> : !apps.length ? <p className="text-2xs text-faint">{T.appsNone}</p> : (
<div className="max-h-40 space-y-0.5 overflow-y-auto">
{apps.map((a) => (
<label key={a.id} className="flex items-center gap-2 text-xs" data-access-key-app={a.id}>
<input type="checkbox" className="accent-[var(--accent)]" checked={limited.includes(a.id)}
onChange={(e) => setLimited((cur) => (e.target.checked ? [...cur, a.id] : cur.filter((x) => x !== a.id)))} />
{a.name}
</label>
))}
</div>
)}
</fieldset>
</div>
</Modal>
)
}
/** 一把密钥最近的请求 */
function KeyRequests({ id }: { id: string }) {
const [rows, setRows] = useState<RecentRequestRow[] | null>(null)
const [days, setDays] = useState<number | null>(null)
const [error, setError] = useState<unknown>(null)
const load = useCallback(() => {
setError(null)
api.accessKeys.requests(id).then(({ items, retain_days }) => { setRows(items.map(requestRow)); setDays(retain_days) })
.catch(setError)
}, [id])
useEffect(load, [load])
return <RecentRequests rows={rows} error={error} onRetry={load} summary={days != null ? T.requestsHint(days) : undefined}
attr={{ 'data-access-key-requests': id }} />
}
function requestRow(r: AccessKeyRequestRow, i: number): RecentRequestRow {
return {
key: `${r.at}-${i}`, at: r.at, tone: r.status >= 200 && r.status < 300 ? 'ok' : 'bad',
result: RECENT_REQUESTS_TEXT.status(r.status), what: `${r.method} ${r.route}`, detail: r.ip,
ref: r.run_id ? { label: RECENT_REQUESTS_TEXT.run, id: r.run_id } : null,
}
}
// ---------------------------------------------------------------- 接口说明
function Reference() {
const [ref, setRef] = useState<AccessKeyReference | null>(null)
const [error, setError] = useState<unknown>(null)
const load = useCallback(() => {
setError(null)
api.accessKeys.reference().then(setRef).catch(setError)
}, [])
useEffect(load, [load])
if (error) return <ErrorState error={error} onRetry={load} />
if (!ref) return <Skeleton rows={6} height={24} />
return (
<section className="space-y-3 text-xs" data-access-key-reference={ref.routes.length}>
<SectionBar title={T.reference} hint={T.referenceHint} />
<dl className="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-1">
<dt className="text-faint">{T.refAuth}</dt>
<dd><code className="mono">{ref.header}</code></dd>
<dt className="text-faint">{T.refBase}</dt>
<dd>{ref.base_url ? <code className="mono">{ref.base_url}</code> : <span className="text-faint">{T.refBaseNone}</span>}</dd>
</dl>
<p className="text-2xs text-dim">{T.refIdempotent(ref.idempotency_header)}</p>
<p className="text-2xs text-dim">{T.refRate(ref.rate_per_minute)}</p>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-2xs">
<thead className="bg-bg text-left text-faint">
<tr>
<th className="px-2 py-1.5 font-medium">{T.refColMethod}</th>
<th className="px-2 py-1.5 font-medium">{T.refColPath}</th>
<th className="px-2 py-1.5 font-medium">{T.refColScope}</th>
<th className="px-2 py-1.5 font-medium">{T.refColSummary}</th>
<th className="px-2 py-1.5 font-medium">{T.refColErrors}</th>
</tr>
</thead>
<tbody className="divide-y">
{ref.routes.map((r) => (
<tr key={`${r.method} ${r.path}`} className="align-top" data-access-key-route={`${r.method} ${r.path}`}>
<td className="mono px-2 py-1.5">{r.method}</td>
<td className="mono px-2 py-1.5">{r.path}</td>
<td className="mono px-2 py-1.5">{r.scope === ref.implicit_scope ? T.refImplicit : r.scope}</td>
<td className="px-2 py-1.5">
{r.summary}
{r.idempotent && <span className="chip ml-1">{T.refNeedsKey}</span>}
</td>
<td className="mono px-2 py-1.5 text-faint">{r.errors.join(' ')}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-2xs">
<span className="text-faint">{T.refCommon}:</span>
<code className="mono">{ref.common_errors.join(' ')}</code>
</div>
</section>
)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { AuditRow } from '../../typesApp'
const KINDS: Record<string, string> = {
auth: '登录方式', user: '用户', group: '组', app: '应用', grant: '授权', endpoint: '端点', capability: '能力',
workflow_version: '工作流版本', channel: '通知渠道',
workflow_version: '工作流版本', channel: '通知渠道', access_key: '访问密钥',
}
export function AuditTab() {
+18 -11
View File
@@ -6,7 +6,7 @@ import {
useRadioGroup,
} from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { AUTH_MODE_LABEL, IDENTITY_TEXT as T, ROLE_LABEL } from '../../lib/terms'
import { ACCESS_KEY_TEXT, AUTH_MODE_LABEL, IDENTITY_TEXT as T, ROLE_LABEL } from '../../lib/terms'
import { useAtLeast, useAuth } from '../../store/auth'
import type { AuthMode, GroupRow, Role, UserRow } from '../../typesApp'
@@ -192,22 +192,29 @@ function UsersSection({ mode }: { mode: AuthMode }) {
<span className="mono font-medium">{u.username}</span>
{u.display_name && u.display_name !== u.username && <span className="text-dim">{u.display_name}</span>}
{u.source === 'oidc' && <span className="chip">{T.sourceOidc}</span>}
{u.kind === 'service' && <span className="chip" data-user-service="">{ACCESS_KEY_TEXT.serviceTag}</span>}
{u.disabled && <span className="chip text-faint">{T.disabled}</span>}
</div>
<div className="text-2xs text-faint">
{u.email ? `${u.email} · ` : ''}{u.last_login_at ? T.lastLogin(formatDateTime(u.last_login_at)) : T.neverLogin}
{u.kind === 'service' ? T.serviceManaged
: <>{u.email ? `${u.email} · ` : ''}{u.last_login_at ? T.lastLogin(formatDateTime(u.last_login_at)) : T.neverLogin}</>}
</div>
</div>
<select className="field h-7 w-28 py-0 text-xs" value={u.role} aria-label={`${T.role}:${u.username}`}
onChange={(e) => void update(u, { role: e.target.value as Role })}>
{ROLES.map((r) => <option key={r} value={r}>{ROLE_LABEL[r]}</option>)}
</select>
{u.source === 'local' && (
<button className="btn btn-xs btn-ghost" onClick={() => void resetPassword(u)}>{T.passwordReset}</button>
{/* 服务账号的角色固定是使用者、没有密码,停用和启用在「访问密钥」里做(服务端同样拒改) */}
{u.kind !== 'service' && (
<>
<select className="field h-7 w-28 py-0 text-xs" value={u.role} aria-label={`${T.role}:${u.username}`}
onChange={(e) => void update(u, { role: e.target.value as Role })}>
{ROLES.map((r) => <option key={r} value={r}>{ROLE_LABEL[r]}</option>)}
</select>
{u.source === 'local' && (
<button className="btn btn-xs btn-ghost" onClick={() => void resetPassword(u)}>{T.passwordReset}</button>
)}
<button className="btn btn-xs btn-ghost" onClick={() => void update(u, { disabled: !u.disabled })}>
{u.disabled ? T.enable : T.disable}
</button>
</>
)}
<button className="btn btn-xs btn-ghost" onClick={() => void update(u, { disabled: !u.disabled })}>
{u.disabled ? T.enable : T.disable}
</button>
</div>
))}
</div>
+147 -1
View File
@@ -26,6 +26,11 @@ export interface Principal {
* 设置,开关随认人、登录接口一起给(服务端 api/auth.principal_full);老服务端不给时按关
*/
user_personal_apps?: boolean
/**
* 管理员开没开个人访问密钥(auth.personal_keys,docs/design/next-round/04-对外集成与触达.md A1):关着时不是管理员的人在设置
* 里看不到「访问密钥」(建不了,已有的个人密钥也不生效)。老服务端不给时按关
*/
personal_keys?: boolean
}
export interface UserRow {
@@ -38,6 +43,8 @@ export interface UserRow {
source: 'local' | 'oidc'
last_login_at: string | null
created_at: string
/** person 人;service 服务账号(只持有访问密钥,在「设置 → 访问密钥」里管)。老服务端没有这一项,按人算 */
kind?: 'person' | 'service'
}
export interface GroupRow {
@@ -50,7 +57,8 @@ export interface GroupRow {
/** 选人用的名录(GET /api/directory,搭建者及以上只读):只有用户名、显示名、是否停用和组名 */
export interface Directory {
users: { username: string; display_name: string; disabled: boolean }[]
/** service:服务账号(能拿应用授权,不能被派待办:岗位绑定、改派里不能选它) */
users: { username: string; display_name: string; disabled: boolean; service?: boolean }[]
groups: { name: string; label: string }[]
}
@@ -864,3 +872,141 @@ export interface CapabilityReviewOutcome {
error: string | null
code: string | null
}
// ---------------------------------------------------------------------------
// 访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7,服务端 api/access_keys.py、auth/keys.py)
// ---------------------------------------------------------------------------
/** 能申请的权限(auth/keys.SCOPES);另有不用申请的 me */
export type AccessKeyScope = 'apps.read' | 'apps.run' | 'tasks.read' | 'tasks.respond' | 'reports.read'
/** 一把密钥现在能不能用:能用 / 已撤销 / 已过期 / 拥有者停用了 */
export type AccessKeyState = 'active' | 'revoked' | 'expired' | 'owner_disabled'
export interface AccessKeyRow {
id: string
name: string
/** 明文开头一截(agl_ 加随机串前 8 位),只用来认是哪一把 */
prefix: string
scopes: AccessKeyScope[]
/** 限定的应用;null 是不限应用 */
app_ids: string[] | null
owner: { id: string; username: string | null; display_name: string | null; kind: 'person' | 'service' | null }
/** 个人访问密钥(拥有者是人);false 是服务账号的 */
personal: boolean
state: AccessKeyState
expires_at: string
revoked_at: string | null
revoked_by: string | null
last_used_at: string | null
last_used_ip: string | null
/** 轮换出来的新密钥记着从哪一把换来 */
rotated_from: string | null
created_by: string | null
created_at: string
}
/** 建、轮换的回包:明文 key 只在这一次出现 */
export interface AccessKeyIssued extends AccessKeyRow {
key: string
/** 轮换时:旧的那一把(24 小时后到期) */
previous?: AccessKeyRow
}
export interface ServiceAccountRow {
id: string
username: string
display_name: string
disabled: boolean
created_by: string | null
created_at: string
/** 没撤销的密钥有几把 */
keys: number
}
export interface AccessKeySettings {
/** 开没开登录:关登录时访问密钥不生效 */
login_on: boolean
/** 个人访问密钥的开关(管理员开,缺省关) */
personal_keys: boolean
admin: boolean
can_create_personal: boolean
scopes: AccessKeyScope[]
/** 新建时缺省勾上的(只有读的) */
read_scopes: AccessKeyScope[]
/** 只给个人密钥的(回复待办) */
personal_scopes: AccessKeyScope[]
expires_default_days: number
expires_max_days: number
}
/** 一把密钥的一次请求(access_key_requests):路由模板,不是带参数的路径 */
export interface AccessKeyRequestRow {
at: string
method: string
route: string
status: number
run_id: string | null
ip: string | null
}
/** 接口说明(GET /api/access-keys/reference):从门禁那张表生成 */
export interface AccessKeyReference {
base_url: string | null
header: string
idempotency_header: string
scopes: AccessKeyScope[]
implicit_scope: string
personal_scopes: AccessKeyScope[]
rate_per_minute: number
common_errors: string[]
routes: { method: string; path: string; scope: string; summary: string; errors: string[]; idempotent: boolean }[]
}
/** 钩子最近收到的请求(GET /api/apps/{id}/hooks/{name}/receipts) */
export interface HookReceiptRow {
idem_key: string
status: 'processing' | 'accepted' | 'rejected'
received_at: string | null
run_id: string | null
task_id: string | null
detail: string | null
}
export interface HookReceipts {
items: HookReceiptRow[]
/** 最近一小时验签失败了几次(不落库,只在服务端进程里数) */
signature_failures: number
window_s: number
}
/**
* 视图数据 agentlab.viewdata/1(GET /api/apps/{id}/views/{view}/data,A5):给外部系统拉封存的数。前端暂时不用,类型照服务端
* apps/resolve.view_data 写
*/
export interface ViewData {
format: 'agentlab.viewdata/1'
app: { id: string; version: number }
view: { id: string; kind: 'latest_sealed' | 'on_demand' }
state: 'ok' | 'empty' | 'running' | 'error'
/** 按需视图:规整后的参数 */
params?: Record<string, unknown>
run_id: string | null
manifest_hash: string | null
stamp: Record<string, any> | null
metrics: {
node: string
node_label: string
caliber: string | null
caliber_version: string | null
id: string
name: string
unit: string
/** 原值;遮罩的为 null */
value: unknown
text: string
state: 'ok' | 'masked' | 'missing'
evidence: { run_id: string; eid: string | null; node: string; metric: string }
}[]
message?: string
}
+369
View File
@@ -0,0 +1,369 @@
// 设置 → 访问密钥的检查(docs/design/next-round/04-对外集成与触达.md A1、A6、A7)。名字不叫 check-tokens:那个名字查的是设计令牌。
//
// 守的是:
// - 管理员:个人访问密钥的开关;服务账号(建、停用前确认);全部密钥(拥有者、状态、到期、最近使用、权限);
// - 新建密钥:缺省只勾读的权限;服务账号不能勾回复待办;有效期必填、最长 365 天;建好后明文只显示这一次,点「我已保存」就没了;
// - 轮换:先确认,签出的新密钥同样只显示一次;撤销:先确认;
// - 「最近的请求」(和钩子的收件记录同一个组件):接口写路由模板,成功、被拒分开上色;
// - 接口说明:照服务端那张表列出方法、路径、权限、说明、错误码,要幂等键的标出来;
// - 使用者:管理员开了个人访问密钥才有这一页,只看自己的,建的时候不选拥有者;没开时设置里没有这一页;
// - 关登录:照实说访问密钥不生效,不给建密钥的入口,接口说明照看;
// - 身份页:服务账号那一行标出来,没有角色、密码这些控件。
//
// 不写库:/api/auth/me、/api/access-keys/*、/api/apps、/api/users、/api/groups、/api/auth/settings 全部伪造,伪造的接口改一份
// 内存里的数据。其余写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-access-keys.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.ACCESS_KEYS_ONLY ?? '').split(',').filter(Boolean)
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const waitFor = async (page, fn, ms = 5000) => {
for (let t = 0; t < ms; t += 100) {
if (await fn().catch(() => false)) return true
await page.waitForTimeout(100)
}
return false
}
const clone = (v) => JSON.parse(JSON.stringify(v))
// ---------------------------------------------------------------- 夹具(通用示例名)
const ADMIN = { mode: 'local', username: 'root', display_name: '管理员', role: 'admin', groups: [], verified: true }
const USER = { mode: 'local', username: 'pia', display_name: '小皮', role: 'user', groups: [], verified: true }
const OFF = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const SCOPES = ['apps.read', 'apps.run', 'tasks.read', 'tasks.respond', 'reports.read']
const settingsOf = (over = {}) => ({
login_on: true, personal_keys: false, admin: true, can_create_personal: false, scopes: SCOPES,
read_scopes: ['apps.read', 'tasks.read', 'reports.read'], personal_scopes: ['tasks.respond'],
expires_default_days: 90, expires_max_days: 365, ...over,
})
const SERVICES = [
{ id: 'sa1', username: 'svc-portal', display_name: '门户', disabled: false, created_by: 'root', created_at: '2026-10-01T02:00:00Z', keys: 1 },
]
const keyRow = (over = {}) => ({
id: 'k1', name: '门户拉数', prefix: 'agl_AbCd1234', scopes: ['apps.read', 'reports.read'], app_ids: null,
owner: { id: 'sa1', username: 'svc-portal', display_name: '门户', kind: 'service' }, personal: false, state: 'active',
expires_at: '2026-12-30T02:00:00Z', revoked_at: null, revoked_by: null, last_used_at: '2026-10-15T08:00:00Z',
last_used_ip: '10.0.0.5', rotated_from: null, created_by: 'root', created_at: '2026-10-01T02:00:00Z', ...over,
})
const KEYS = [
keyRow(),
keyRow({ id: 'k2', name: '旧脚本', prefix: 'agl_ZzYy9876', personal: true, state: 'revoked', revoked_at: '2026-10-10T02:00:00Z',
owner: { id: 'u2', username: 'pia', display_name: '小皮', kind: 'person' }, last_used_at: null, scopes: ['apps.read'] }),
]
const APPS = [
{ id: 'app-w', name: '周报看板', description: '', icon: 'app', status: 'active', published_version: 1, level: 'published',
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: null, updated_at: '2026-10-01T02:00:00Z', scope: 'shared' },
{ id: 'app-p', name: '我的看板', description: '', icon: 'app', status: 'active', published_version: null, level: null,
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: null, updated_at: '2026-10-01T02:00:00Z', scope: 'personal' },
]
const REFERENCE = {
base_url: 'https://agentlab.example.com', header: 'Authorization: Bearer agl_…', idempotency_header: 'Idempotency-Key',
scopes: SCOPES, implicit_scope: 'me', personal_scopes: ['tasks.respond'], rate_per_minute: 120,
common_errors: ['key_invalid', 'key_expired', 'key_revoked', 'key_route_forbidden'],
routes: [
{ method: 'GET', path: '/api/auth/me', scope: 'me', summary: '这把密钥认出来是谁', errors: [], idempotent: false },
{ method: 'GET', path: '/api/apps/{app_id}/views/{view_id}/data', scope: 'apps.read', summary: '视图数据', errors: ['app_not_found'],
idempotent: false },
{ method: 'POST', path: '/api/apps/{app_id}/entries/{entry_id}', scope: 'apps.run', summary: '按入口发起一个实例',
errors: ['key_idempotency_required', 'key_files_unsupported'], idempotent: true },
],
}
const REQUESTS = [
{ at: '2026-10-15T08:00:00Z', method: 'GET', route: '/api/apps/{app_id}/views/{view_id}/data', status: 200, run_id: 'run-abc12345', ip: '10.0.0.5' },
{ at: '2026-10-15T07:59:00Z', method: 'GET', route: '/api/runs', status: 403, run_id: null, ip: '10.0.0.5' },
]
async function open(path, { me = ADMIN, settings = settingsOf(), keys = KEYS, services = SERVICES, users = [] } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { settings: clone(settings), keys: clone(keys), services: clone(services), switches: [], serviceWrites: [],
creates: [], revokes: [], rotates: [], requests: [], references: 0 }
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/auth\/me$/, (route) => json(route, me))
await page.route(/\/api\/apps(\?.*)?$/, (route) => json(route, { items: APPS, can_create: { shared: true, personal: false } }))
await page.route(/\/api\/users$/, (route) => json(route, { items: users }))
await page.route(/\/api\/groups$/, (route) => json(route, { items: [] }))
await page.route(/\/api\/auth\/settings$/, (route) => json(route, { mode: me.mode, env_locked: false, oidc: {}, user_can_chat: false }))
await page.route(/\/api\/access-keys(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
const parts = new URL(req.url()).pathname.split('/').slice(2) // ['access-keys', ...]
const [, a, b] = parts
if (a === 'settings') {
if (req.method() === 'PUT') {
state.switches.push(req.postDataJSON())
state.settings.personal_keys = req.postDataJSON().personal_keys
}
return json(route, state.settings)
}
if (a === 'reference') { state.references += 1; return json(route, REFERENCE) }
if (a === 'service-accounts') {
if (req.method() === 'GET') return json(route, { items: state.services })
const body = req.postDataJSON()
state.serviceWrites.push({ method: req.method(), id: b ?? null, body })
if (req.method() === 'POST') {
const row = { id: `sa${state.services.length + 1}`, username: body.username, display_name: body.display_name || body.username,
disabled: false, created_by: 'root', created_at: '2026-10-16T02:00:00Z', keys: 0 }
state.services.push(row)
return json(route, row, 201)
}
const row = state.services.find((s) => s.id === b)
Object.assign(row, body)
return json(route, row)
}
if (!a) {
if (req.method() === 'GET') return json(route, { items: state.keys })
const body = req.postDataJSON()
state.creates.push(body)
const row = keyRow({ id: `k${state.keys.length + 1}`, name: body.name, prefix: 'agl_NewKey00', scopes: body.scopes,
app_ids: body.app_ids, personal: !body.service_account_id, last_used_at: null })
state.keys.unshift(row)
return json(route, { ...row, key: 'agl_NewKey00-only-shown-once-secret' }, 201)
}
if (b === 'revoke') {
state.revokes.push(a)
const row = state.keys.find((k) => k.id === a)
Object.assign(row, { state: 'revoked', revoked_at: '2026-10-16T02:00:00Z' })
return json(route, row)
}
if (b === 'rotate') {
state.rotates.push(a)
const old = state.keys.find((k) => k.id === a)
const row = { ...old, id: `${a}-r`, prefix: 'agl_Rotated0', rotated_from: a }
state.keys.unshift(row)
return json(route, { ...row, key: 'agl_Rotated0-new-secret-after-rotation', previous: old })
}
if (b === 'requests') { state.requests.push(a); return json(route, { items: REQUESTS, retain_days: 90 }) }
return json(route, { detail: '没有这个接口' }, 404)
})
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
return { page, state, errors }
}
const confirm = async (page, label) => {
const dlg = page.locator('[role="dialog"]').last()
await dlg.waitFor()
await dlg.getByRole('button', { name: label, exact: true }).click()
}
const writes = []
const browser = await chromium.launch({ executablePath: CHROME })
// ================================================================
await section('管理员:个人访问密钥的开关、服务账号、全部密钥', async () => {
const { page, state, errors } = await open('/settings/keys')
check('设置页有「访问密钥」页签', await page.getByRole('tab', { name: '访问密钥', exact: true }).count() === 1,
(await page.locator('[role=tab]').allInnerTexts()).join(' | '))
const root = page.locator('[data-access-keys="admin"]')
check('按管理员显示', await waitFor(page, async () => await root.count() === 1))
check('说清访问密钥按使用者的权限算、看到的一律遮罩', (await root.innerText()).includes('按使用者的权限算'))
const sw = page.locator('[data-access-keys-personal-switch]')
check('个人访问密钥的开关缺省关', await sw.getAttribute('data-access-keys-personal-switch') === 'off')
// 受控的开关:等服务端回了才翻过来,所以点它、再等它变
await sw.locator('input').click()
check('打开开关:只改这一项', await waitFor(page, async () => state.switches.length === 1)
&& state.switches[0].personal_keys === true
&& await waitFor(page, async () => await sw.getAttribute('data-access-keys-personal-switch') === 'on'), JSON.stringify(state.switches))
check('服务账号列出来,标着在用的密钥数', (await page.locator('[data-service-account="svc-portal"]').innerText()).includes('1 把在用的密钥'))
await page.locator('[data-service-account-create]').click()
const form = page.locator('[data-service-account-form]')
check('新建服务账号:用户名预填 svc- 开头', await form.locator('[data-service-account-username]').inputValue() === 'svc-')
await form.locator('[data-service-account-username]').fill('svc-bi')
await page.locator('[data-service-account-save]').click()
check('建服务账号按填的用户名发', await waitFor(page, async () => state.serviceWrites.length === 1)
&& state.serviceWrites[0].body.username === 'svc-bi', JSON.stringify(state.serviceWrites))
check('建好之后列出来', await waitFor(page, async () => await page.locator('[data-service-account="svc-bi"]').count() === 1))
await page.locator('[data-service-account="svc-portal"] [data-service-account-toggle="disable"]').click()
await confirm(page, '停用')
check('停用前先确认,确认后才发', await waitFor(page, async () => state.serviceWrites.length === 2)
&& state.serviceWrites[1].body.disabled === true && state.serviceWrites[1].id === 'sa1')
const list = page.locator('[data-access-key-list]')
check('全部密钥:服务账号的和别人的个人密钥都在', await list.locator('[data-access-key]').count() === 2)
const active = list.locator('[data-access-key="门户拉数"]')
check('一把密钥:开头一截、拥有者、到期、最近使用、权限', (await active.innerText()).includes('agl_AbCd1234')
&& (await active.innerText()).includes('svc-portal') && (await active.innerText()).includes('到期')
&& (await active.innerText()).includes('最近使用') && await active.locator('[data-access-key-scopes] .chip').count() === 2)
const revoked = list.locator('[data-access-key="旧脚本"]')
check('撤销了的照实标出,不再给轮换和撤销', await revoked.getAttribute('data-access-key-state') === 'revoked'
&& (await revoked.innerText()).includes('已撤销') && await revoked.locator('[data-access-key-rotate]').count() === 0)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('新建密钥:缺省只勾读的;服务账号不能回复待办;明文只显示一次', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-key-create]').click()
const form = page.locator('[data-access-key-form]')
await form.waitFor()
check('拥有者缺省是服务账号(个人访问密钥没开)', await waitFor(page, async () =>
await form.locator('[data-access-key-owner]').inputValue() === 'sa1'))
const checked = async (s) => form.locator(`[data-access-key-scope="${s}"] input`).isChecked()
check('缺省只勾读的三项', await checked('apps.read') && await checked('tasks.read') && await checked('reports.read')
&& !(await checked('apps.run')))
check('服务账号:回复待办勾不了、说清原因', await form.locator('[data-access-key-scope="tasks.respond"] input').isDisabled()
&& (await form.locator('[data-access-key-scope="tasks.respond"]').innerText()).includes('不能被派待办'))
check('个人应用不在限定应用里', await form.locator('[data-access-key-app="app-p"]').count() === 0
&& await form.locator('[data-access-key-app="app-w"]').count() === 1)
check('名称空着不能建', await page.locator('[data-access-key-save]').isDisabled())
await form.locator('[data-access-key-name]').fill('BI 拉数')
await form.locator('[data-access-key-days]').fill('400')
check('有效期超过 365 天不能建、说清最长几天', await page.locator('[data-access-key-save]').isDisabled()
&& (await form.innerText()).includes('最长 365 天'))
await form.locator('[data-access-key-days]').fill('30')
await form.locator('[data-access-key-scope="apps.run"] input').check()
await form.locator('[data-access-key-app="app-w"] input').check()
await page.locator('[data-access-key-save]').click()
const body = (await waitFor(page, async () => state.creates.length === 1)) ? state.creates[0] : null
check('建的时候发名称、权限、限定应用、有效期、服务账号', body?.name === 'BI 拉数' && body.expires_in_days === 30
&& body.service_account_id === 'sa1' && JSON.stringify(body.app_ids) === '["app-w"]'
&& body.scopes.includes('apps.run') && !body.scopes.includes('tasks.respond'), JSON.stringify(body))
const once = page.locator('[data-access-key-once="BI 拉数"]')
check('明文只显示这一次:值、复制、说明', await waitFor(page, async () => await once.count() === 1)
&& (await once.locator('[data-access-key-secret]').innerText()).includes('agl_NewKey00-only-shown-once-secret')
&& (await once.innerText()).includes('只显示这一次') && await once.getByRole('button', { name: '复制' }).count() === 1)
await once.locator('[data-access-key-once-done]').click()
check('点「我已保存」之后页面上没有明文了', await page.locator('[data-access-key-secret]').count() === 0
&& !(await page.locator('body').innerText()).includes('only-shown-once-secret'))
})
await section('轮换与撤销:先确认;轮换出的新密钥只显示一次', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-key-rotate="门户拉数"]').click()
const dlg = page.locator('[role="dialog"]').last()
await dlg.waitFor()
check('轮换前说清旧密钥 24 小时后到期', (await dlg.innerText()).includes('24 小时后到期'))
await confirm(page, '轮换')
check('确认后才发轮换', await waitFor(page, async () => state.rotates.length === 1) && state.rotates[0] === 'k1')
check('新密钥只显示这一次', await waitFor(page, async () =>
(await page.locator('[data-access-key-secret]').innerText()).includes('agl_Rotated0-new-secret-after-rotation')))
check('列表里标出由轮换签发', await waitFor(page, async () =>
(await page.locator('[data-access-key-list]').innerText()).includes('由轮换签发')))
await page.locator('[data-access-key-revoke="门户拉数"]').last().click()
await page.locator('[role="dialog"]').last().getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(200)
check('撤销取消了就不发', state.revokes.length === 0)
await page.locator('[data-access-key-revoke="门户拉数"]').last().click()
await confirm(page, '撤销')
check('确认后才撤销', await waitFor(page, async () => state.revokes.length === 1))
})
await section('最近的请求:路由模板、成功和被拒分开', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-key-requests-toggle="门户拉数"]').click()
const box = page.locator('[data-access-key-requests="k1"]')
check('按这把密钥取', await waitFor(page, async () => state.requests.includes('k1')))
check('两条请求,写的是路由模板', await waitFor(page, async () => await box.locator('[data-recent-request]').count() === 2)
&& (await box.innerText()).includes('/api/apps/{app_id}/views/{view_id}/data'))
check('成功的、被拒的分开标', await box.locator('[data-recent-request="ok"]').count() === 1
&& (await box.locator('[data-recent-request="bad"]').innerText()).includes('被拒 403'))
check('写明保留几天', (await box.locator('[data-recent-requests-summary]').innerText()).includes('保留 90 天'))
})
await section('接口说明:照服务端那张表列出', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-keys-view="reference"]').click()
const ref = page.locator('[data-access-key-reference]')
// 开发模式下 effect 执行两次,取一次、两次都算
check('取了接口说明', await waitFor(page, async () => state.references >= 1 && await ref.count() === 1))
check('每个接口一行:方法、路径、权限、说明、错误码', await ref.locator('[data-access-key-route]').count() === 3
&& (await ref.locator('[data-access-key-route="POST /api/apps/{app_id}/entries/{entry_id}"]').innerText()).includes('key_idempotency_required'))
check('要幂等键的标出来', (await ref.locator('[data-access-key-route="POST /api/apps/{app_id}/entries/{entry_id}"]').innerText()).includes('要幂等键'))
check('me 写不用申请', (await ref.locator('[data-access-key-route="GET /api/auth/me"]').innerText()).includes('不用申请'))
check('认证方式、对外访问地址、限流、通用错误码都写了', (await ref.innerText()).includes('Authorization: Bearer agl_…')
&& (await ref.innerText()).includes('https://agentlab.example.com') && (await ref.innerText()).includes('每分钟最多 120 次')
&& (await ref.innerText()).includes('key_route_forbidden'))
})
await section('使用者:开了个人访问密钥才有这一页,只看自己的', async () => {
const { page, state } = await open('/settings/keys', {
me: { ...USER, personal_keys: true },
settings: settingsOf({ admin: false, personal_keys: true, can_create_personal: true }),
keys: [keyRow({ id: 'k9', name: '我的脚本', personal: true, owner: { id: 'u2', username: 'pia', display_name: '小皮', kind: 'person' } })],
})
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('页签:偏好、通知、访问密钥', tabs.some((t) => t.includes('访问密钥')) && tabs.length === 3, tabs.join(' | '))
check('按本人显示:没有开关、没有服务账号', await page.locator('[data-access-keys="self"]').count() === 1
&& await page.locator('[data-access-keys-personal-switch]').count() === 0 && await page.locator('[data-service-accounts]').count() === 0)
check('标题是「我的访问密钥」', (await page.locator('[data-access-key-list]').innerText()).includes('我的访问密钥'))
await page.locator('[data-access-key-create]').click()
const form = page.locator('[data-access-key-form]')
await form.waitFor()
check('建自己的:不选拥有者,回复待办可以勾', await form.locator('[data-access-key-owner]').count() === 0
&& await form.locator('[data-access-key-scope="tasks.respond"] input').isEnabled())
await form.locator('[data-access-key-name]').fill('日报')
await page.locator('[data-access-key-save]').click()
check('发的是个人访问密钥(不带服务账号)', await waitFor(page, async () => state.creates.length === 1)
&& !('service_account_id' in state.creates[0]), JSON.stringify(state.creates[0]))
})
await section('使用者:没开个人访问密钥时设置里没有这一页', async () => {
const { page } = await open('/settings/keys', { me: USER, settings: settingsOf({ admin: false }) })
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('没有「访问密钥」页签', !tabs.some((t) => t.includes('访问密钥')), tabs.join(' | '))
check('地址纠正到偏好设置', new URL(page.url()).pathname === '/settings/prefs', page.url())
})
await section('关登录:照实说访问密钥不生效,不给建的入口', async () => {
const { page } = await open('/settings/keys', { me: OFF, settings: settingsOf({ login_on: false }) })
check('说清未开启登录时不生效', await waitFor(page, async () => await page.locator('[data-access-keys-login-off]').count() === 1)
&& (await page.locator('[data-access-keys-login-off]').innerText()).includes('未开启登录'))
check('没有建密钥、建服务账号的入口', await page.locator('[data-access-key-create]').count() === 0
&& await page.locator('[data-service-account-create]').count() === 0)
await page.locator('[data-access-keys-view="reference"]').click()
check('接口说明照看', await waitFor(page, async () => await page.locator('[data-access-key-route]').count() === 3))
})
await section('身份页:服务账号那一行标出来,没有角色和密码', async () => {
const users = [
{ id: 'u1', username: 'root', display_name: '管理员', email: null, role: 'admin', disabled: false, source: 'local',
last_login_at: null, created_at: '2026-10-01T02:00:00Z', kind: 'person' },
{ id: 'sa1', username: 'svc-portal', display_name: '门户', email: null, role: 'user', disabled: false, source: 'local',
last_login_at: null, created_at: '2026-10-01T02:00:00Z', kind: 'service' },
]
const { page } = await open('/settings/identity', { users })
const svc = page.locator('[data-user="svc-portal"]')
check('服务账号标出来、说清在哪管', await waitFor(page, async () => await svc.locator('[data-user-service]').count() === 1)
&& (await svc.innerText()).includes('设置 → 访问密钥'))
check('服务账号没有角色、重设密码、停用这些控件', await svc.locator('select').count() === 0 && await svc.locator('button').count() === 0)
check('人照旧有角色', await page.locator('[data-user="root"] select').count() === 1)
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)
+2 -1
View File
@@ -98,6 +98,7 @@ const ORDER = [
'inbox', // 待办箱:会签进度、超时横幅、动作确认的哈希卡、核实待办
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
'personal', // 个人应用:建、刷新、提交审核
@@ -114,7 +115,7 @@ const ESTIMATE = {
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30,
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30,
// 普通用户建应用:还没实测,按同类页面估
blueprint: 90, personal: 45, records: 30, files: 30,
}
+22 -1
View File
@@ -114,7 +114,8 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z', draft: clone(spec),
draft_bindings: { slots: {} }, created_by: '检查脚本', ...(backfill ? { backfill } : {}) }
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [],
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], backfills: [], turns: [], turnPatches: [], conversations: 0,
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], receipts: [], backfills: [], turns: [], turnPatches: [],
conversations: 0,
archives: 0 }
await page.route(/\/api\//, (route) => {
@@ -165,6 +166,15 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
const [, , , , , name, action] = url.pathname.split('/')
if (!name) return json(route, { items: state.hooks })
const hook = decodeURIComponent(name)
// 收件记录(04 细案 A6):形状同服务端 process/hooks.recent_receipts
if (action === 'receipts') {
state.receipts.push(hook)
return json(route, { window_s: 3600, signature_failures: 2, items: [
{ idem_key: 'pay-2', status: 'rejected', received_at: '2026-10-14T03:00:00Z', run_id: null, task_id: null,
detail: '没有在等这个关联键的待办(可能已处理过,或还没走到这一步)' },
{ idem_key: 'pay-1', status: 'accepted', received_at: '2026-10-14T02:00:00Z', run_id: 'run-hook-1', task_id: 'task-1',
detail: null }] })
}
if (action === 'rotate') {
state.rotates.push(hook)
return json(route, { id: `h-${hook}`, name: hook, kind: 'signal', entry_id: null, enabled: true, has_secret: true,
@@ -481,6 +491,17 @@ await section('钩子:按名字列出声明和密钥状态;生成的密钥
&& (await shown.locator('[data-app-hook-secret-value]').innerText()).includes('secret-of-paid-0123456789')
&& (await shown.innerText()).includes('只显示这一次') && await shown.getByRole('button', { name: '复制' }).count() >= 1)
check('……附入站地址', (await shown.locator('[data-app-hook-url]').innerText()).endsWith('/api/hooks/h-paid'))
// 收件记录(04 细案 A6):生成过密钥的钩子能展开,列出状态、原因和发起的实例,另写近 1 小时验签失败几次
await page.locator('[data-app-hook-receipts-toggle="old"]').click()
const receipts = page.locator('[data-app-hook-receipts="old"]')
check('收件记录按名字取', await waitFor(page, async () => state.receipts.includes('old')), JSON.stringify(state.receipts))
check('……被拒的标红、写原因,接受的带实例链接', await waitFor(page, async () => await receipts.locator('[data-recent-request]').count() === 2)
&& await receipts.locator('[data-recent-request="bad"] [data-recent-request-detail]').innerText().then((t) => t.includes('没有在等'))
&& await receipts.locator('[data-recent-request="ok"] a[href$="/instances/run-hook-1"]').count() === 1)
check('……近 1 小时验签失败的次数照实写', (await receipts.locator('[data-recent-requests-summary]').innerText()).includes('验签失败 2 次'))
// 伪造的列表在生成之后不变:paid 那一行仍是没生成过密钥
check('没生成过密钥的钩子没有收件记录可看', await paid.locator('[data-app-hook-state="none"]').count() === 1
&& await paid.locator('[data-app-hook-receipts-toggle]').count() === 0)
await page.locator('[data-app-hook-delete="old"]').click()
await page.locator('[role="dialog"]').getByRole('button', { name: '删除密钥' }).click()
check('删除密钥按名字发', await waitFor(page, async () => state.hookDeletes.length === 1) && state.hookDeletes[0] === 'old')