Compare commits

..
27 changed files with 203 additions and 851 deletions
-150
View File
@@ -1,150 +0,0 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-System-Frontend"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
-6
View File
@@ -1,6 +0,0 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -1,47 +0,0 @@
# Agent-System-Frontend 采纳 development-governance-v2 治理套件 任务卡
任务主记录:本文件随 PR 描述维护(PR 链接见下方"结果与验证")。
状态:进行中;owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-System-Frontend
- R0(initial_main_sha):8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b;工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-System-Frontend-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 5670c68e | implementation | cursor→antigravity→pi | run e3fd063d(dm-server pinned,attempts=1) | cursor / not-observable | success | d64dbc3a15d2(apply 快进) | 否 | /tmp/gov-receipt-Agent-System-Frontend-r1.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 2026-10-17T06:53:57Z | 一致 |
## 结果与验证(完成后填写)
- 结果:NOT_RUN
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 影响与剩余项:NOT_RUN
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T071544Z | `scripts/dm governance check --repo .` | d64dbc3a15d2 | 0 | SELF | ok fixtures;[输出](evidence/governance-adoption/00-20261017T071544Z.log) sha256:b4f09e161961151b |
| 20261017T071544Z | `scripts/agent-exec validate --repo .` | d64dbc3a15d2 | 0 | SELF | validate: ok;[输出](evidence/governance-adoption/01-20261017T071544Z.log) sha256:06f07c50e89b4bbf |
| 20261017T071544Z | `python3 /tmp/gov-neg/adopt_cases.py .` | d64dbc3a15d2 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](evidence/governance-adoption/02-20261017T071544Z.log) sha256:f74ae904b59d73e4 |
@@ -1,19 +0,0 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -1,7 +0,0 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
validate: ok
@@ -1,13 +0,0 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
[object] Agent-System-Frontend-wt-feature-governance-adoption HEAD=d64dbc3a15d29520402f0c97199cb88b3e4aa9af
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=6fa967fff457
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
-66
View File
@@ -1,66 +0,0 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
-67
View File
@@ -1,67 +0,0 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
-18
View File
@@ -35,21 +35,3 @@
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
AI 实施单元仅通过 `scripts/agent-exec run` 运行(依据 AGENT-EXECUTION.md),并经 `scripts/agent-exec apply` 集成。
-1
View File
@@ -10,5 +10,4 @@
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 治理:`scripts/agent-exec validate --repo .` — 校验 AGENT-EXECUTION.md 执行路由配置;`scripts/dm governance check --repo .` — 校验治理套件(Guidelines 固定版本、采纳记录与执行配置)。
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
+18 -13
View File
@@ -1,5 +1,6 @@
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
// 忘记密码唯一处置回归:管理员生成一次性登录密码 → 用户密码登录 → 强制改密
//(one_time_password 门只要新密码 + 确认);
// 用户行不再有恢复提案入口,登录页只保留用户名 + 密码,不提供一次性 Key 登录 tab。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
@@ -51,38 +52,42 @@ async function adminLogin(page: Page) {
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
test('管理员生成一次性登录密码:登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
const row = zone.locator('tr', { hasText: USER })
await row.getByRole('button', { name: '重置临时密码' }).click()
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
const password = await temporaryPassword.inputValue()
await expect(row.getByRole('button', { name: '恢复提案' })).toHaveCount(0)
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
const oneTimePassword = zone.locator('input[aria-label="一次性密码"]')
await expect(oneTimePassword).toBeVisible({ timeout: 10_000 })
await expect(zone.getByText('使用后必须强制改密')).toBeVisible()
const password = await oneTimePassword.inputValue()
expect(password.length).toBeGreaterThan(0)
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
//(一次性密码展示框内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户使用临时密码登录后进入强制改密门。
// 3) 用户使用一次性登录密码登录后进入 one_time_password 强制改密门:
// 只填新密码 + 确认,不再要求当前密码。
await passwordLogin(page, USER, password)
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1)
await expect(gate.getByText('无需再次输入一次性密码')).toBeVisible()
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(password)
await expect(pw).toHaveCount(2)
await pw.nth(0).fill(USER_PW)
await pw.nth(1).fill(USER_PW)
await pw.nth(2).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
-40
View File
@@ -1,40 +0,0 @@
# 通用规范采纳记录
- 项目/负责人:Agent-System-Frontend / weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`(项目规则与制度放此处;入口为 `policy/README.md`)
- 过程文档位置:任务卡在 `.task/`(如 `.task/feature-governance-adoption.md`);任务主记录为对应 PR 描述
- 版本权威:本仓 gitlink `deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;可读标签:无(候选 `0.1.0-draft.12`,尚未发布)
- 批准/生效依据:`feature/governance-adoption PR(Agent-System-Frontend)`
- 替代范围和迁移说明:无替代既有采纳记录。遗留:`policy/README.md` 与 `PROJECT.md` 的采纳基线行仍写有旧路径 `development/guidelines`(指向 Dev 仓),本次未改,后续单独修正为本仓 gitlink。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 采纳:所有任务的硬边界、授权与完成声明基线 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 采纳:本仓日常交付、Bug 排障与会话交接均按此执行,任务卡与 PR 为记录载体 | manual |
| standards/collaboration.md | 并行共同起点、两级分支、节点同步与陈旧候选拒绝 | 采纳:存在 feature 分支并行开发,合入前需同步最新目标分支 | manual;有真实平台负例再登记 enforced |
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:单 owner、单仓串行合入,无共享 CI 预算或多任务合入窗口 | 不适用 |
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 采纳:允许 AI 协助实施,配置见 AGENT-EXECUTION.md | AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:governance check + 正负例(`.task/feature-governance-adoption.md`) |
| standards/verification.md | 验证与结论 | 采纳:功能与修复的验收须附实际命令输出 | manual;本地命令见 PROJECT.md「验证」节(CI 仅运行治理检查) |
| standards/module-contracts.md | 模块边界、公开接口与契约测试 | 采纳:`src/` 模块与 Core 契约形状测试(vitest)遵循该契约 | manual |
| workflows/design-verification.md | 功能增量的设计与验收对应 | 采纳:新功能与跨模块变更须写明验收与验证范围 | manual |
| standards/testability.md | 模块测试、mock 边界与可独立验证的设计 | 采纳:单元与契约测试依赖保持最小,真实适配器单独验证 | manual |
| standards/failure-containment.md | 故障影响控制、运行配额与故障注入 | 不适用:本仓为前端 SPA(构建产物由后端内嵌),无自有服务进程、队列或持久化,故障影响控制由后端仓承担 | 不适用 |
## 项目差异
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| 无 | 无 | 无 | 无 |
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
## 本次升级或首次接入验证
- 入口:`AGENTS.md`(治理入口,合并 Guidelines 短入口与本仓「Agent 执行与新项目准入」)、`AGENT-EXECUTION.md`、`PROJECT.md`、本文件;相对链接指向 `deps/development-guidelines/...`,目标文件已在固定 SHA 中核对存在。
- 占位符:本文件与 `AGENTS.md` 中的模板占位符已全部替换。
- 版本获取:`governance init --adopt`(dm-server bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)以固定 SHA 检出 `deps/development-guidelines`(2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb)与 `deps/development-manager`(bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)。
- 冲突检查:既有 `AGENTS.md`、`PROJECT.md`、`AGENT-EXECUTION.md` 原样保留;本仓无其他采纳记录(如 `policy/adoption.md`),无需引用。
- 正负例与 Review/CI 证据:见 `.task/feature-governance-adoption.md`「结果与验证」节。
- 回退:删除 `.gitmodules` 中两个治理条目及 `deps/` gitlink,并移除本次生成的治理脚本与 `.gitea/workflows/governance.yml`;`AGENTS.md`、`PROJECT.md` 恢复到采纳前版本。
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
set -euo pipefail
exec "$(dirname "$0")/dm" agent-exec "$@"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
dm="$root/deps/development-manager"
die() { echo "dm: $*" >&2; exit 2; }
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
read -r mode pin _stage _path <<<"$entry"
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
[ "$head" = "$pin" ] \
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|| die "无法读取 deps/development-manager 的工作树状态"
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
bin="$cache/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
mkdir -p "$cache"
cache=$(cd "$cache" && pwd -P)
bin="$cache/dm-server-$pin"
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
rm -rf "$work"
die "构建 dm-server 失败($pin)"
fi
mv -f "$work/dm-server" "$bin"
rm -rf "$work"
fi
exec "$bin" "$@"
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in
main|master)
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
exit 1 ;;
esac
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
exit 1
}
root=$(git rev-parse --show-toplevel)
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
"$root/scripts/agent-exec" admit --repo "$root"
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
set -euo pipefail
exec "$(dirname "$0")/dm" task "$@"
+8 -33
View File
@@ -16,17 +16,10 @@ const BASE = ''
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required'
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
export interface RecoveryProposal {
id: string; userId: string; owner: string
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
version: number; generation: number; createdBy: string
createdAt: string; expiresAt: string
}
* passwordChangeMode 由服务端从会话派生;grant_setup 与 one_time_password
*(管理员生成的一次性登录密码会话)免当前密码,current_required(管理员
* 指定/生成的初始密码首登)必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
@@ -87,14 +80,6 @@ export const api = {
auth.beginSession()
return me
}),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
@@ -107,22 +92,12 @@ export const api = {
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
// 忘记密码的唯一处置:管理员生成一次性登录密码(明文只在本次响应出现);
// 用户以该密码登录后进入 one_time_password 强制改密门,设置新密码后才放行。
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; oneTimePassword: string; mustChangePassword: boolean }>(r)),
// ---- 本人:可选资料(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
+1 -1
View File
@@ -28,7 +28,7 @@ function ProfilesPanelWrapper() {
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、生成一次性登录密码(管理员发放制)', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
+15 -16
View File
@@ -1,8 +1,9 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
// grant_setup 免当前密码(激活/恢复 Key 兑换),one_time_password 免当前密码
//(管理员生成的一次性登录密码会话,只需新密码 + 确认),current_required 必须
// 校验当前密码(管理员指定/生成的初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
import { Icon, BrandMark } from '@ui'
@@ -61,7 +62,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员生成一次性登录密码,使用后须立即改密</p>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
@@ -75,11 +76,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
}
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)与 one_time_password
*(一次性登录密码会话)免当前密码。 */
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
/** 设密门只认服务端声明的模式;未知/缺失模式一律拒绝放行。 */
export function isPasswordChangeMode(mode: string | null | undefined): mode is PasswordChangeMode {
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
}
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
@@ -95,9 +102,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
let active = true
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
if (me.owner !== owner || me.sessionScope !== 'set_password_only' || !isPasswordChangeMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录;忘记密码请联系管理员重新生成一次性登录密码。')
return
}
setMode(me.passwordChangeMode)
@@ -137,11 +143,13 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
{mode === 'current_required' && <>
<p className="lg-hint">首次登录必须更换初始密码;「当前密码」填写刚才登录用的初始密码。</p>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
</>}
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
{mode === 'one_time_password' && <p className="lg-hint">你正在使用管理员生成的一次性登录密码登录,请直接设置新密码,无需再次输入一次性密码。</p>}
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
@@ -164,15 +172,6 @@ export function humanLoginError(raw: string): string {
return raw.replace(/^\d{3}:\s*/, '')
}
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
+9 -87
View File
@@ -1,12 +1,9 @@
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
import { recoveryStatusLabel } from '../recovery'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
const refreshProposal = async (id: string) => {
const updated = await api.getMyRecoveryProposal(id)
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
return updated
}
const confirm = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
} catch (error) {
const current = await refreshProposal(proposal.id).catch(() => null)
setErr(current?.status === 'confirmed'
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
} finally { setBusy(false) }
}
const rotate = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
} catch (error) {
await refreshProposal(proposal.id).catch(() => null)
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
} finally { setBusy(false) }
}
const redeem = async () => {
if (!grant) return
setBusy(true); setErr(''); setNotice('')
try {
await api.redeemLoginKey(grant.value)
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
} catch (error) {
const session = await api.me().catch(() => null)
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
location.reload()
return
}
await refreshProposal(grant.proposalId).catch(() => null)
setGrant(null)
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
} finally { setBusy(false) }
}
return <SettingsGroup title="我的账号与恢复">
return <SettingsGroup title="我的账号">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
@@ -122,20 +57,7 @@ export function MyAccountSettings() {
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
<div className="ad-row">
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
</div>
</div>)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
{grant && <div className="ad-reset">
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
</div>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密);生成后原密码仍可正常登录,用原密码登录会使未使用的一次性登录密码作废。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
+30 -95
View File
@@ -2,17 +2,16 @@
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api, type RecoveryProposal } from '../api'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码只有一个处置——
// 管理员生成一次性登录密码,用户使用后必须强制改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
@@ -23,18 +22,12 @@ export function UsersPanel() {
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'one-time' } | null>(null)
const [busy, setBusy] = useState(false)
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshProposals = async (userId: string) => {
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
@@ -49,46 +42,16 @@ export function UsersPanel() {
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const resetPassword = async (u: AdminUserRow) => {
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminResetUserPassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.oneTimePassword, kind: 'one-time' })
setNotice(`已为 ${u.owner} 生成一次性登录密码,原密码仍可正常登录。该用户用一次性密码登录后必须立即设置新密码(强制改密);若改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。`)
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const openProposals = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
await refreshProposals(u.userId)
}
const propose = async () => {
if (!propFor) return
setErr(''); setNotice(''); setBusy(true)
try {
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
await refreshProposals(propFor.userId)
} catch (e) {
if (String(e).includes('409')) {
await resetPassword(propFor)
return
}
setErr(recoveryProposalErrorMessage(String(e)))
} finally { setBusy(false) }
}
const revokeProposal = async (p: RecoveryProposal) => {
if (!propFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
await refreshProposals(propFor.userId)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
@@ -104,60 +67,32 @@ export function UsersPanel() {
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <div className="ad-reset">
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
<p className="ad-hint">{generatedCredential.kind === 'initial'
? `初始密码仅显示一次,请立即安全交付给 ${generatedCredential.owner}。`
: `一次性登录密码仅显示一次,请立即安全交付给 ${generatedCredential.owner};使用后必须强制改密。`}</p>
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{propFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
</div>
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
<table className="ad-table">
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
<tbody>
{proposals.map(p => (
<tr key={p.id}>
<td className="ad-mono">{p.id}</td>
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
<td className="ad-dim">{p.createdBy}</td>
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
)}</td>
</tr>
))}
</tbody>
</table>
)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && <span className="ad-row">
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
</span>}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点该用户行的「生成一次性登录密码」:生成不会使原密码失效。用户用一次性密码登录后必须设置新密码才能继续;若用户改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。</p>}
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
</div>
)
}
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
// 两种语义必须分开表达。
describe('requiresCurrentPassword', () => {
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
})
describe('humanChangePasswordError', () => {
it('后端 current password incorrect 映射为当前密码不正确', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
})
describe('humanRedeemError', () => {
it('401/409/429 语义分离', () => {
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
})
})
describe('recoveryStatusLabel', () => {
it('覆盖全部五种提案状态', () => {
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
expect(recoveryStatusLabel[status]).toBeTruthy()
}
})
})
describe('recoveryProposalHint', () => {
it('pending/confirmed 给出下一步,终态不给', () => {
expect(recoveryProposalHint('pending')).toContain('本人确认')
expect(recoveryProposalHint('confirmed')).toContain('新密码')
expect(recoveryProposalHint('completed')).toBe('')
})
})
describe('recoveryProposalErrorMessage', () => {
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
})
it('未识别错误剥状态码原样透出', () => {
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
})
})
-29
View File
@@ -1,29 +0,0 @@
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
import type { RecoveryProposal } from './api'
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
pending: '待本人确认',
confirmed: '已确认,待兑换设密',
completed: '已完成',
revoked: '已撤销',
expired: '已过期',
}
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
return ''
}
export function recoveryProposalErrorMessage(raw: string): string {
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
}
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
if (raw.includes('403')) return '需要管理员身份'
if (raw.includes('404')) return '用户不存在或已被移除'
return raw.replace(/^\d{3}:\s*/, '')
}
+15 -8
View File
@@ -1,7 +1,7 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
import { humanChangePasswordError, humanLoginError, isPasswordChangeMode, requiresCurrentPassword } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -31,14 +31,21 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
describe('requiresCurrentPassword(强制改密门模式)', () => {
it('current_required(管理员指定/生成的初始密码首登)必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
it('one_time_password(一次性登录密码受限会话)只需新密码与确认,免当前密码', () => {
expect(requiresCurrentPassword('one_time_password')).toBe(false)
})
it('设密门只接受服务端声明的三种模式,其余一律拒绝', () => {
expect(isPasswordChangeMode('grant_setup')).toBe(true)
expect(isPasswordChangeMode('current_required')).toBe(true)
expect(isPasswordChangeMode('one_time_password')).toBe(true)
expect(isPasswordChangeMode(undefined)).toBe(false)
expect(isPasswordChangeMode('')).toBe(false)
expect(isPasswordChangeMode('temporary_password')).toBe(false)
})
})
+107
View File
@@ -0,0 +1,107 @@
// 忘记密码唯一处置守护:管理员生成一次性登录密码 → 用户登录 → 强制改密。
// 恢复提案 UI/API 已整体移除;登录页只保留用户名 + 密码一种凭据入口。
import { describe, expect, it } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
const read = (...parts: string[]) => readFileSync(join(SRC, ...parts), 'utf8')
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else if (/\.(ts|tsx)$/.test(p)) out.push(p)
}
return out
}
describe('恢复提案已移除', () => {
it('src 不再调用恢复提案或登录 Key 兑换端点', () => {
const hits = walk(SRC).filter(p => /recovery-proposals|RecoveryProposal|login-key\/redeem|redeemLoginKey/.test(readFileSync(p, 'utf8')))
expect(hits.map(p => relative(SRC, p))).toEqual([])
})
})
describe('一次性登录密码 API 契约', () => {
const apiSrc = read('api.ts')
it('POST /api/admin/users/{owner}/one-time-password 并消费 oneTimePassword 字段', () => {
const line = apiSrc.split('\n').find(l => l.includes('adminIssueOneTimePassword:')) ?? ''
expect(line).toContain('/api/admin/users/${encodeURIComponent(owner)}/one-time-password`')
expect(line).toContain("method: 'POST'")
expect(line).toContain('oneTimePassword: string')
})
it('旧的临时密码重置端点与字段已移除', () => {
expect(apiSrc).not.toContain('adminResetUserPassword')
expect(apiSrc).not.toContain('temporaryPassword')
expect(apiSrc).not.toMatch(/\/api\/admin\/users\/\$\{[^}]+\}\/password`/)
})
})
describe('管理员用户管理:唯一忘记密码动作', () => {
const panel = read('components', 'adminSections.tsx')
it('调用一次性登录密码端点并展示 oneTimePassword', () => {
expect(panel.match(/api\.adminIssueOneTimePassword\(/g)).toHaveLength(1)
expect(panel).toContain('out.oneTimePassword')
expect(panel).not.toContain('temporaryPassword')
})
it('每行只有「生成一次性登录密码」一个按钮,无恢复提案或重置临时密码入口', () => {
expect(panel.match(/>生成一次性登录密码</g)).toHaveLength(1)
expect(panel).not.toContain('恢复提案')
expect(panel).not.toContain('重置临时密码')
})
it('一次性密码文案标明使用后强制改密,明文只在一次性展示框出现', () => {
expect(panel).toContain('使用后必须强制改密')
expect(panel.match(/aria-label="一次性密码"/g)).toHaveLength(1)
expect(panel).not.toMatch(/localStorage|sessionStorage|clipboard/)
})
it('文案标明生成不使原密码失效;原密码登录则作废未用的一次性密码且不强制改密', () => {
expect(panel).not.toMatch(/原密码(已|立即|随生成即)失效/)
expect(panel).toContain('原密码仍可正常登录')
expect(panel).toContain('未使用的一次性登录密码随即作废')
expect(panel).toContain('不会被要求改密')
})
})
describe('登录页:仅用户名 + 密码', () => {
const login = read('components', 'LoginPage.tsx')
const loginPage = login.slice(login.indexOf('export function LoginPage'), login.indexOf('export function requiresCurrentPassword'))
it('只有一个用户名输入与一个密码输入,无第二凭据入口', () => {
expect(loginPage.match(/<input /g)).toHaveLength(2)
expect(loginPage.match(/type="password"/g)).toHaveLength(1)
expect(loginPage).toContain('api.login(')
expect(loginPage).not.toMatch(/role="tab"|login-key|alk_|redeem/i)
})
it('忘记密码指引指向一次性登录密码且标明须改密', () => {
expect(loginPage).toContain('生成一次性登录密码,使用后须立即改密')
})
})
describe('强制改密门:one_time_password 模式', () => {
const login = read('components', 'LoginPage.tsx')
const gate = login.slice(login.indexOf('export function ChangePasswordGate'), login.indexOf('export function humanLoginError'))
it('门放行判定经 isPasswordChangeMode,接受 one_time_password', () => {
expect(gate).toContain('isPasswordChangeMode(me.passwordChangeMode)')
expect(gate).toContain("mode === 'one_time_password'")
})
it('当前密码输入只在 current_required 下渲染,且提示不再指向一次性登录密码', () => {
const currentBlock = gate.slice(gate.indexOf("{mode === 'current_required' && <>"), gate.indexOf('</>}'))
expect(currentBlock).toContain('id="forced-current"')
expect(currentBlock).not.toContain('一次性登录密码')
expect(gate.match(/id="forced-current"/g)).toHaveLength(1)
})
})
describe('我的账号:无恢复提案确认', () => {
const settings = read('components', 'MyAccountSettings.tsx')
it('只给出联系管理员生成一次性登录密码的指引,没有可操作的恢复按钮', () => {
expect(settings).toContain('请联系管理员生成一次性登录密码')
expect(settings).toContain('强制改密')
expect(settings).not.toMatch(/由本人确认|重新生成设密能力|兑换/)
})
it('不声称生成一次性登录密码会使原密码失效', () => {
expect(settings).not.toMatch(/原密码(已|立即|随生成即)失效/)
expect(settings).toContain('原密码仍可正常登录')
})
})