Commit 6f48c19483dc5b6d2dca09ebdd7a1a6a80a31558
1 parent
86a45035
feat(auth): Phase 0 —— 种子账号 + 登录页 + 路由守卫跑通(38 号文档)
远程部署前置需求:登录鉴权 + 公司级多租户隔离。后端 auth.js/jwt-auth.js(注册/登录/ JWT-cookie/OAuth)本来就已经可用,这次只是把它接到前端。 - scripts/seed-admin-user.js(npm run seed:admin):幂等种子脚本,建出 admin/hex1234, role=admin。 - web/src/composables/useAuth.js + views/Login.vue:登录态 + 登录页。 - router/index.js 全局 beforeEach:未登录跳 /login?redirect=...,登录后带着原目标跳回。 - main.js 全局 401 拦截器:cookie 中途过期同样兜住,不止页面切换那一刻。 - AppNav.vue:显示用户名 + 退出登录。 - 顺手修:App.vue 原来无条件渲染 MasterChatDrawer,登录页也会露出这个悬浮入口, 改成登录且非登录页才显示。 业务路由还没加锁(Phase 1 的事),登录后依然能看到现状全部数据,符合这一阶段的验收边界。 真实浏览器走过一遍全部流程(跳转/登录/跳回/直接刷新受保护路由/退出),npm run build 通过。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
10 changed files
with
424 additions
and
2 deletions
docs/README.md
| @@ -49,6 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | @@ -49,6 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | ||
| 49 | | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | 49 | | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | |
| 50 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | 50 | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | |
| 51 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | | 51 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | |
| 52 | +| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | 📝 **Phase 0 已完成(2026-08-25),Phase 1-3 未开工**。远程部署前置需求:登录鉴权 + 公司级多租户隔离。现状 `auth.js`/`jwt-auth.js`(注册/登录/JWT-cookie/OAuth)+ WebSocket 鉴权都已可用,真正缺的是①把已有的锁挂到目前零防护的 `company.js`/`agent-chat.js`/`okr.js`/`tasks.js`/`devloop.js`/`graph-run.js`(含双挂载点)等路由上、②`company.json` 从 0 新增 `ownerId` 归属概念(无 SQL 表,纯文件)。已拍板:存量公司迁移统一划给种子账号 `admin`/`hex1234`(不强制改密);OKR 跟随 company 隔离(含独立的遗留 `/api/okr` 挂载点);`arena.js`/`evolution.js` 核实为疑似废弃代码(前端零引用、5 个月未改动),只挂登录不投入隔离;`dream.js` 系统级,挂 `requireAdmin`。**Phase 0**:种子脚本 + 登录页 + 路由守卫 + 401 拦截器 + 退出登录,真实浏览器走通(含顺手修复的 `MasterChatDrawer` 登录页泄漏)。Phase 1(全站要求登录)→ Phase 2(`router.param('companyId')` 归属校验,最大工作量)→ Phase 3(聊天隔离验证+前端收尾)待开工 | | ||
| 52 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | 53 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | |
| 53 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | 54 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | |
| 54 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | | 55 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | |
docs/todo/system-design/38-auth-multi-tenant-isolation.md
0 → 100644
| 1 | +# 38 · 登录鉴权 + 公司级多租户隔离 — 实现计划 | ||
| 2 | + | ||
| 3 | +> **日期**:2026-08-25 | ||
| 4 | +> **触发**:代码要部署到远程服务器,现状零鉴权——任何人打开域名直接进系统,`:companyId` 全部路由零权限校验,谁都能看/改任何一家公司。用户要求:登录注册(种子一个默认账号)+ 全站 cookie 鉴权 + 每个用户只看得到自己的 company(system agent 全局共享不受影响)+ 每个用户的聊天记录互相隔离。 | ||
| 5 | +> **前置结论(已在同一会话里做过一次调研,详见对话记录,不重复展开)**:这不是从零建鉴权系统。`server/routes/auth.js` + `server/middleware/jwt-auth.js` 已经是一套完整可用的注册/登录/JWT-cookie/GitHub+Google OAuth 实现,`/ws/claude` 的 WebSocket 鉴权也已经做完了。真正缺的是两件事:①把这套已有的锁**挂到**目前完全不设防的路由上;②公司归属这个概念**从 0 到 1** 新建(`company.json` 目前连 `owner` 字段都没有)。 | ||
| 6 | +> **已拍板的产品决策**:存量公司(`data/companies/` 下现有的全部)迁移时统一划给种子默认账号;种子账号 `admin` / `hex1234`(首次部署后建议改密,见 §七)。 | ||
| 7 | +> **状态**:实现计划,未开工。 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +## 一、现状盘点(本次调研的精确定位,含文件:行号) | ||
| 12 | + | ||
| 13 | +### 1.1 已经有、不用动的部分 | ||
| 14 | +- **认证核心**:`server/routes/auth.js`(279 行)—— `POST /register`(`bcrypt.hash`,`SALT_ROUNDS`)、`POST /login`(`bcrypt.compare`)、`POST /logout`、`GET /me`、GitHub/Google OAuth 全套,真实可用。 | ||
| 15 | +- **JWT + Cookie 中间件**:`server/middleware/jwt-auth.js` —— `signAndSetCookie`(httpOnly、`sameSite:'lax'`、生产环境 `secure:true`、7 天过期、cookie 名 `lemon_token`)、`extractUser`(先读 cookie 再 fallback `Authorization: Bearer`)、`requireAuth`/`optionalAuth`/`requireAdmin` 三个中间件。生产环境 `JWT_SECRET` 未设直接拒绝启动(第 24-33 行),开发环境有兜底密钥但每次启动打警告。 | ||
| 16 | +- **用户表**:`server/services/user-db.js:37-51`,`users` 表已有 `id/email/password_hash/display_name/avatar_url/provider/provider_id/github_login/role/api_key/created_at/last_login`,字段齐全,不用改 schema。 | ||
| 17 | +- **WebSocket 鉴权**:`server/routes/claude-chat-ws.js:396-411` 握手阶段解析 `Cookie` header、验证 JWT,未授权直接 close(4001)。同源部署(Vite dev 代理 `/api`+`/ws` 到 :3010,生产环境同一个 Express 进程托管前后端)下浏览器会自动带上 cookie,前端不需要为 WS 连接单独处理鉴权。 | ||
| 18 | +- **已经挂了 `requireAuth`/`optionalAuth` 的路由**:`claude-chat.js`、`innovator.js`、`master-chat.js`、`users.js`(仅 admin)——这几个文件里 `req.user.id` 的用法(尤其 `claude-chat.js` 用 `req.user.id` 隔离上传目录 + 每个 session 校验 `session.userId !== String(req.user.id)`)是一份**现成的、已经做对的参考实现**,公司归属校验直接照这个模式抄,不用重新设计。 | ||
| 19 | + | ||
| 20 | +### 1.2 完全零防护的部分 | ||
| 21 | +`server/index.js:82-99` 挂载的路由里,**除上面 4 个之外全部零 `requireAuth`**:`agent-chat.js`、`company.js`、`okr.js`、`tasks.js`、`devloop.js`、`graph-run.js`(含它在 `/api/company` 下的第二个挂载点 `.companyRouter`,`server/routes/graph-run.js:571`)、`arena.js`、`config.js`、`evolution.js`、`dream.js`、`whatsapp-auth.js`。`open-api.js` 是个例外——它自己有一套独立的 `OPEN_API_KEYS` 鉴权(面向外部 Skill 市场调用方),**必须显式排除**在 cookie 鉴权闸门之外,不能被顺手扫进去。 | ||
| 22 | + | ||
| 23 | +### 1.3 公司归属:真正的 0→1 | ||
| 24 | +`server/services/company-service.js` 全程没有 `owner`/`user_id` 概念: | ||
| 25 | +- `get(companyId)`(约 300 行)直接读 `data/companies/{id}/company.json`,`list()`(308 行起)无条件遍历 `DATA_DIR` 下所有目录返回全部公司。 | ||
| 26 | +- `create({id, name, goal, description})`(243 行)不接收 owner 参数。 | ||
| 27 | +- **没有 `companies` SQL 表**——公司身份完全是文件系统 + `company.json`,不是 DB 行。这点利好这次改造:加 `ownerId` 字段是往一个 JSON 文件里加一个键,不需要写 SQL migration。 | ||
| 28 | + | ||
| 29 | +`:companyId` 作为路由参数被信任的范围比"只有 `/list`"大得多:`company.js` 一个文件就有 50+ 条 `/:companyId/...` 路由(goals/tasks/hire/fire/org-graph/pipelines/files/...)。 | ||
| 30 | + | ||
| 31 | +### 1.4 两个容易漏掉的边角 | ||
| 32 | +- **`server/routes/devloop.js`**:路由用的是 `/projects/:pid/...`,**不是** `:companyId`——URL 上看不出公司归属。但项目创建时强制要求 `company_id`(`devloop.js:46-48`),`path-resolver.js` 也按 `pid → {root_path, mode, company_id}` 缓存(`path-resolver.js:74,87,218-219,317`)——归属信息在,只是要多一跳:校验 `:pid` 时得先查出它的 `company_id`,再判断这个 company 是不是当前用户的,不能直接复用 `company.js` 那种"路由参数本身就是 companyId"的中间件写法。 | ||
| 33 | +- **`server/routes/okr.js`**(挂载于 `/api/okr`,前端消费方是 `OkrDashboard.vue`)是一套**独立的、目前完全没有公司维度的全局 OKR 视图**——`OkrService` 类本身支持按 `companyId` 分目录存储(`okr-service.js:28-33`),但 `okr.js:20` 实例化时是 `new OkrService()`,**没传 companyId**,落在"向后兼容"的全局模式,读写的是 `data/okr/`,跟任何公司都不挂钩。这是一个此前就存在、这次调研才浮出水面的独立缺口,不是这次改造引入的——要不要把它并入这次的公司隔离范围,见 §六。 | ||
| 34 | + | ||
| 35 | +--- | ||
| 36 | + | ||
| 37 | +## 二、目标与非目标 | ||
| 38 | + | ||
| 39 | +**目标**: | ||
| 40 | +1. 未登录访问任何页面 → 跳转登录页;登录后正常使用。 | ||
| 41 | +2. 每个普通用户只能看到/操作自己名下的 company(含其 goals/tasks/pipelines/devloop 项目/graph-runs/聊天记录)。 | ||
| 42 | +3. `system/agents/` 全局共享,不受影响(本来就是文件系统级共享,天然满足,见 §一验证过)。 | ||
| 43 | +4. 种子一个默认账号 `admin`,密码 `hex1234`,存量公司全部归它。 | ||
| 44 | + | ||
| 45 | +**非目标**(这次不做,未来需要再单独立项): | ||
| 46 | +- 不做团队/组织级共享(一个 company 只属于一个 user,不支持"邀请协作者")。 | ||
| 47 | +- 不重新设计 GitHub/Google OAuth(已经能用,保持现状,登录页并列展示"账号密码登录"和"第三方登录"两个入口即可)。 | ||
| 48 | +- 不处理 `/api/open/*` 的 Skill 市场访问控制(它自己的 `OPEN_API_KEYS` 体系是独立需求)。 | ||
| 49 | + | ||
| 50 | +--- | ||
| 51 | + | ||
| 52 | +## 三、实现方案 | ||
| 53 | + | ||
| 54 | +### 3.1 归属校验的统一收口模式 | ||
| 55 | +每个持有 `:companyId` 的 router 用 `router.param('companyId', ...)` 收口一次,而不是逐条路由里手写检查——这样"新增一条路由忘了查权限"这类问题在写路由的时候就不可能发生(少了一步就报错,不是少了一步就悄悄放行): | ||
| 56 | + | ||
| 57 | +```js | ||
| 58 | +router.param('companyId', (req, res, next, companyId) => { | ||
| 59 | + const company = companyService.get(companyId); | ||
| 60 | + if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` }); | ||
| 61 | + if (req.user.role !== 'admin' && company.ownerId !== req.user.id) { | ||
| 62 | + return res.status(404).json({ error: `公司 ${companyId} 不存在` }); // 404 不是 403:不暴露"这个 id 存在但不是你的" | ||
| 63 | + } | ||
| 64 | + req.company = company; | ||
| 65 | + next(); | ||
| 66 | +}); | ||
| 67 | +``` | ||
| 68 | +返回 404 而不是 403 是刻意的:403 会告诉攻击者"这个 company id 是有效的,只是你无权访问",等于把 id 空间泄漏出去;未登录/无权都统一表现为"不存在"。 | ||
| 69 | + | ||
| 70 | +`company.js`、`graph-run.js`(两个挂载点都要加)都走这个模式。`okr.js`/`tasks.js` 目前不接 `:companyId` 路由参数(见 §一.4),需要看 §六 的决定再定具体接入方式。 | ||
| 71 | + | ||
| 72 | +### 3.2 `devloop.js` 的间接校验 | ||
| 73 | +`router.param('pid', ...)` 里先用 `path-resolver` 查出 `company_id`,再复用 3.1 同一份归属判断逻辑(抽成共享函数,不要复制一份判断代码到两个地方)。 | ||
| 74 | + | ||
| 75 | +### 3.3 前端 | ||
| 76 | +- 新增登录页(账号密码表单 + 第三方登录按钮,直接调用已有的 `/api/auth/login`、`/api/auth/github`)。 | ||
| 77 | +- `web/src/router/index.js` 加全局 `beforeEach`:调 `GET /api/auth/me`(`optionalAuth`,不 401)判断登录态,未登录且目标路由非登录页 → 重定向登录页。 | ||
| 78 | +- 一个全局的 fetch/axios 拦截器:收到 401 → 清本地状态、跳登录页(避免每个页面各自处理一次 401)。 | ||
| 79 | +- 退出登录入口(调 `POST /api/auth/logout` 清 cookie)。 | ||
| 80 | + | ||
| 81 | +### 3.4 种子账号 | ||
| 82 | +不新增脚本,直接复用已有的 `POST /api/auth/register`(或等价的一次性 seed 脚本包一层 `userDb.createUser` + `bcrypt.hash('hex1234', SALT_ROUNDS)`),部署时跑一次。 | ||
| 83 | + | ||
| 84 | +### 3.5 存量数据迁移 | ||
| 85 | +一次性脚本:遍历 `data/companies/*/company.json`,无 `ownerId` 字段的统一写入种子账号的 `user.id`。脚本本身是幂等的(`ownerId` 已存在就跳过),可以放心重复跑。 | ||
| 86 | + | ||
| 87 | +--- | ||
| 88 | + | ||
| 89 | +## 四、分 Phase 实施计划 | ||
| 90 | + | ||
| 91 | +每个 Phase 独立可验收,不依赖后一个 Phase 就能上线(参考 `27-p2-org-graph.md` 的分阶段惯例)。 | ||
| 92 | + | ||
| 93 | +### Phase 0 — 种子账号 + 登录页跑通(不改任何业务路由)✅ 已完成(2026-08-25) | ||
| 94 | +- `scripts/seed-admin-user.js`(`npm run seed:admin`)——幂等,建出 `admin`/`hex1234`、`role=admin`。已在本机跑过,账号 `id=396`。 | ||
| 95 | +- 前端:`web/src/views/Login.vue`(登录页)+ `web/src/composables/useAuth.js`(登录态 + `checkAuth`/`login`/`logout`/`clearAuth`)+ `router/index.js` 的 `beforeEach` 守卫(未登录 → 跳 `/login?redirect=...`,登录后带着原目标跳回)+ `main.js` 的全局 401 拦截器(中途 cookie 过期同样兜住,不止页面切换那一刻)+ `AppNav.vue` 加了用户名/退出按钮。 | ||
| 96 | +- 顺手修了一处过程中发现的泄漏:`App.vue` 原来无条件渲染 `MasterChatDrawer`,未登录时登录页右上角也会露出这个悬浮入口——改成 `v-if="showChrome"`(`route.name !== 'Login' && !!user`),登录页干净了。 | ||
| 97 | +- **验收(真实浏览器走过一遍,非仅测试通过)**:未登录访问 `/company` 等任意页面 → 跳 `/login?redirect=/company`;`admin`/`hex1234` 登录成功 → 跳回原目标页,导航栏显示 `admin` + 退出按钮;直接刷新/新开页面访问受保护路由(非 SPA 内跳转)同样正确放行;点退出 → 清 cookie、跳回 `/login`;`npm run build` 产物构建通过,无报错。因为业务路由还没加锁,登录后依然能看到现状全部数据(等价于"登录墙搭好了,屋里还没上锁"),符合这一阶段的验收边界。 | ||
| 98 | + | ||
| 99 | +### Phase 1 — 全站要求登录(不做归属过滤) | ||
| 100 | +- 给 `agent-chat.js`/`company.js`/`okr.js`/`tasks.js`/`devloop.js`/`graph-run.js`(两个挂载点)/`arena.js`/`config.js`/`evolution.js`/`whatsapp-auth.js` 挂 `requireAuth`,显式排除 `open-api.js`、`auth.js` 自身、`/api/health`。 | ||
| 101 | +- **`dream.js` 挂 `requireAdmin` 而不是 `requireAuth`**(已决策,见 §五)——它是系统级的经验汇流视图,只有 admin 能看,不是"登录即可看"。 | ||
| 102 | +- **验收**:未登录状态下所有 API 都 401;已登录状态下行为跟 Phase 0 完全一致(任何登录用户依然能看到全部公司——归属过滤还没上);非 admin 登录用户访问 `dream.js` 下的接口 403。这一步的回归测试很简单:跑现有测试套件 + 确认没有新增的越权点。 | ||
| 103 | + | ||
| 104 | +### Phase 2 — 公司归属隔离(这次真正的硬骨头) | ||
| 105 | +- `company.json` schema 加 `ownerId`;`create()` 接收 owner 参数(从 `req.user.id` 传入);`list()` 按 `req.user` 过滤(admin 角色不过滤)。 | ||
| 106 | +- 存量数据迁移脚本跑一遍(§3.5),全部划给种子账号。 | ||
| 107 | +- `company.js`/`graph-run.js` 两个挂载点接入 3.1 的 `router.param('companyId', ...)` 统一校验。 | ||
| 108 | +- `devloop.js` 接入 3.2 的间接校验。 | ||
| 109 | +- **`okr.js`(`/api/okr`,独立于 `company.js` 的那套遗留全局 OKR)纳入这次范围**(已决策,见 §五):改成接收 companyId(路由参数或 query),`OkrService` 按 companyId 实例化(`okr-service.js` 本来就支持,只是没接),同样过 3.1 的归属校验;`OkrDashboard.vue` 前端跟着改成需要先选公司。注意 `company.js` 自己的 `okrPath()` 系列路由(`/api/company/:id/okr`,`CompanyDashboard.vue` 团队页 OKR tab 用的是这一套)**已经**在这一步随 `company.js` 一起被 `router.param('companyId')` 收口,不需要额外工作——真正缺的只是这个独立的遗留 `/api/okr` 挂载点。 | ||
| 110 | +- **验收**:种子账号登录看到全部存量公司(无缝衔接);新注册账号登录看到空列表;新账号尝试直接拼 URL 访问种子账号的 company id → 404;新建的 company 只有创建者自己看得到;`/okr` 页面按公司切换后数据不串。这一步要重点测"漏了一条路由"这类问题——建议写一个守卫测试,遍历 `company.js`/`graph-run.js`/`okr.js` 里的路由定义,断言每一条都经过了 `router.param('companyId')` 这个收口点(而不是逐条手工检查,人工抽查容易漏)。 | ||
| 111 | + | ||
| 112 | +### Phase 3 — 聊天记录隔离验证 + 前端收尾体验 | ||
| 113 | +- LemonBoss 侧会话(`data/companies/{id}/agents/{agentId}/{convId}`)理论上 Phase 2 做完就自动隔离了(会话挂在 company 目录下),这一步只是**验证**,不是新写代码:确认没有遗漏的、绕过 company 归属校验就能读会话的接口(比如某个直接按 `convId` 查、不经过 `companyId` 校验的路由)。 | ||
| 114 | +- 前端补全登出后的空状态、加载态、错误提示;确认 18 个路由页面在未登录/无权访问公司时的表现符合预期。 | ||
| 115 | +- **验收**:跨账号真实操作一遍——种子账号和新账号分别登录,交叉尝试访问对方的会话记录,确认全部拒绝。 | ||
| 116 | + | ||
| 117 | +--- | ||
| 118 | + | ||
| 119 | +## 五、开放问题(已拍板) | ||
| 120 | + | ||
| 121 | +1. **`okr.js` 的全局 `OkrService` 要不要并入这次范围?→ 并入。** 用户明确要求"OKR 跟着 company 走,需要隔离"。已更新 §四 Phase 2:`okr.js` 改成接收 companyId 并按其实例化 `OkrService`(本来就支持,只是没接),过同一套归属校验;`OkrDashboard.vue` 前端跟着改成需要先选公司。`company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用)本来就在 `company.js` 里,Phase 2 对 `company.js` 做 `router.param('companyId')` 收口时**顺带**就隔离了,不算独立工作量。 | ||
| 122 | +2. **首次登录是否强制改密 → 不需要。** 种子密码 `hex1234` 保持明文可用,不加强制改密流程。 | ||
| 123 | +3. **`arena.js`/`evolution.js` → 确认为疑似废弃代码,不投入隔离工作量。** 核实结果:`web/src/` 全局搜索零处引用 `/api/arena`(注意与 `open-api.js` 下仍在用的 `/api/open/arena/leaderboard` 是两个不同文件,后者继续保留)、零处引用 `/api/evolution`;两个文件最后一次改动都停在 `2026-03-23`(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂 `requireAuth`(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。 | ||
| 124 | +4. **`dream.js` → 系统级,只有 admin 能看。** 已更新 §四 Phase 1:挂 `requireAdmin` 而不是 `requireAuth`。 | ||
| 125 | +5. **`config.js` 归属未定**:现状调研没有深入,且用户这轮没有明确指示。Phase 1 先按 `arena.js`/`evolution.js` 同款处理——挂 `requireAuth`,不做归属过滤,留到看内部是否有 company 维度数据时再决定要不要提级。 | ||
| 126 | + | ||
| 127 | +--- | ||
| 128 | + | ||
| 129 | +## 六、风险 | ||
| 130 | + | ||
| 131 | +1. **归属校验漏挂**——见 §一.2/§四 Phase 2 的守卫测试建议,这是本次最大的风险点,一条路由漏挂 = 直接的越权数据读写。 | ||
| 132 | +2. **`graph-run.js` 的双挂载点**——`server/index.js:97-98` 同一个文件以两种方式挂载(`/api/graph-runs` 全量 + `/api/company` 下的 `.companyRouter` 子集),改的时候容易只改一处。 | ||
| 133 | +3. **`devloop.js` 的间接校验容易被绕过**——如果新增一条 devloop 路由时忘了走 `:pid` 收口点(比如直接接收 `company_id` 作为 body 参数而不是从已归属校验过的 `:pid` 反查),归属检查会被绕过。建议 code review 时专门盯这一类新路由。 | ||
| 134 | +4. **admin 角色的越权范围**——`requireAdmin`/`role==='admin'` 目前语义是"看得到所有公司",这次种子账号本身就是 admin,等于事实上的超级账号;如果以后要开放更多真实用户注册,需要明确"什么样的用户会被设成 admin"这条边界,避免误操作把普通用户升成了能看所有公司的角色。 |
package.json
| @@ -23,7 +23,8 @@ | @@ -23,7 +23,8 @@ | ||
| 23 | "spec:extract": "node scripts/extract-spec.js", | 23 | "spec:extract": "node scripts/extract-spec.js", |
| 24 | "spec:check": "node scripts/extract-spec.js --check", | 24 | "spec:check": "node scripts/extract-spec.js --check", |
| 25 | "prepare": "bash scripts/install-hooks.sh 2>/dev/null || true", | 25 | "prepare": "bash scripts/install-hooks.sh 2>/dev/null || true", |
| 26 | - "okr:migrate": "node scripts/migrate-goals-to-okr.js" | 26 | + "okr:migrate": "node scripts/migrate-goals-to-okr.js", |
| 27 | + "seed:admin": "node scripts/seed-admin-user.js" | ||
| 27 | }, | 28 | }, |
| 28 | "keywords": [ | 29 | "keywords": [ |
| 29 | "agent", | 30 | "agent", |
scripts/seed-admin-user.js
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +/** | ||
| 3 | + * seed-admin-user.js — 建出登录用的默认种子账号 | ||
| 4 | + * | ||
| 5 | + * 依据: docs/todo/system-design/38-auth-multi-tenant-isolation.md §3.4 / Phase 0 | ||
| 6 | + * | ||
| 7 | + * 账号: admin / hex1234(登录标识存在 users.email 列,不要求真实邮箱格式—— | ||
| 8 | + * 直接走 userDb.createUser 建号,不经过 /api/auth/register 那道邮箱格式校验)。 | ||
| 9 | + * | ||
| 10 | + * 幂等: admin 已存在就跳过,可重复执行。 | ||
| 11 | + * | ||
| 12 | + * 用法: | ||
| 13 | + * node scripts/seed-admin-user.js | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +const path = require('path'); | ||
| 17 | +// bcryptjs 是 server/package.json 的依赖,只装在 server/node_modules 下(三个 workspace | ||
| 18 | +// 各自独立 npm install,不是 pnpm 提升到根目录),scripts/ 在这层目录树里够不着它, | ||
| 19 | +// 用 require.resolve 的 paths 选项显式指去 server/ 底下找。 | ||
| 20 | +const bcrypt = require(require.resolve('bcryptjs', { paths: [path.join(__dirname, '..', 'server')] })); | ||
| 21 | +const userDb = require('../server/services/user-db'); | ||
| 22 | + | ||
| 23 | +const SEED_EMAIL = 'admin'; | ||
| 24 | +const SEED_PASSWORD = 'hex1234'; | ||
| 25 | +const SEED_DISPLAY_NAME = 'admin'; | ||
| 26 | +const SALT_ROUNDS = 10; | ||
| 27 | + | ||
| 28 | +async function main() { | ||
| 29 | + const existing = userDb.findByEmail(SEED_EMAIL); | ||
| 30 | + if (existing) { | ||
| 31 | + console.log(`[seed-admin-user] 账号 "${SEED_EMAIL}" 已存在(id=${existing.id}),跳过`); | ||
| 32 | + return; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + const passwordHash = await bcrypt.hash(SEED_PASSWORD, SALT_ROUNDS); | ||
| 36 | + const user = userDb.createUser({ | ||
| 37 | + email: SEED_EMAIL, | ||
| 38 | + passwordHash, | ||
| 39 | + displayName: SEED_DISPLAY_NAME, | ||
| 40 | + provider: 'local', | ||
| 41 | + }); | ||
| 42 | + | ||
| 43 | + // role 列默认是 'user'(user-db.js 建表语句),种子账号需要 admin 权限 | ||
| 44 | + // 才能看到全部存量公司(38 号文档 §一.4 / §四 Phase 2)——createUser 不接收 | ||
| 45 | + // role 参数,直接改这一行。 | ||
| 46 | + userDb.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id); | ||
| 47 | + | ||
| 48 | + console.log(`[seed-admin-user] 已创建账号 "${SEED_EMAIL}"(id=${user.id}, role=admin)`); | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +main().catch((err) => { | ||
| 52 | + console.error('[seed-admin-user] 失败:', err.message); | ||
| 53 | + process.exit(1); | ||
| 54 | +}); |
web/src/App.vue
| @@ -3,14 +3,23 @@ | @@ -3,14 +3,23 @@ | ||
| 3 | <div class="app-main"> | 3 | <div class="app-main"> |
| 4 | <router-view /> | 4 | <router-view /> |
| 5 | </div> | 5 | </div> |
| 6 | - <MasterChatDrawer /> | 6 | + <MasterChatDrawer v-if="showChrome" /> |
| 7 | </div> | 7 | </div> |
| 8 | </template> | 8 | </template> |
| 9 | 9 | ||
| 10 | <script setup> | 10 | <script setup> |
| 11 | +import { computed } from 'vue' | ||
| 12 | +import { useRoute } from 'vue-router' | ||
| 11 | import { useTheme } from '@/composables/useTheme' | 13 | import { useTheme } from '@/composables/useTheme' |
| 14 | +import { useAuth } from '@/composables/useAuth' | ||
| 12 | import MasterChatDrawer from '@/components/MasterChatDrawer.vue' | 15 | import MasterChatDrawer from '@/components/MasterChatDrawer.vue' |
| 13 | useTheme() | 16 | useTheme() |
| 17 | + | ||
| 18 | +// 登录页不该露出一个能唤起对话的入口——未登录时点了也只会撞 401,且视觉上暴露了 | ||
| 19 | +// "系统里有个东西叫 Master" 这类不必要的信息。 | ||
| 20 | +const route = useRoute() | ||
| 21 | +const { user } = useAuth() | ||
| 22 | +const showChrome = computed(() => route.name !== 'Login' && !!user.value) | ||
| 14 | </script> | 23 | </script> |
| 15 | 24 | ||
| 16 | <style> | 25 | <style> |
web/src/components/AppNav.vue
| @@ -78,6 +78,10 @@ | @@ -78,6 +78,10 @@ | ||
| 78 | {{ themeIcon }} | 78 | {{ themeIcon }} |
| 79 | </button> | 79 | </button> |
| 80 | <span class="nav-version">v5.0</span> | 80 | <span class="nav-version">v5.0</span> |
| 81 | + <div class="nav-user" v-if="user"> | ||
| 82 | + <span class="nav-user-name">{{ user.display_name || user.email }}</span> | ||
| 83 | + <button class="nav-logout-btn" @click="handleLogout" title="退出登录">退出</button> | ||
| 84 | + </div> | ||
| 81 | </div> | 85 | </div> |
| 82 | 86 | ||
| 83 | <!-- New Company Modal --> | 87 | <!-- New Company Modal --> |
| @@ -123,11 +127,18 @@ import { useRoute, useRouter } from 'vue-router' | @@ -123,11 +127,18 @@ import { useRoute, useRouter } from 'vue-router' | ||
| 123 | import axios from 'axios' | 127 | import axios from 'axios' |
| 124 | import { useTheme } from '@/composables/useTheme' | 128 | import { useTheme } from '@/composables/useTheme' |
| 125 | import { useCompanies } from '@/composables/useCompanies' | 129 | import { useCompanies } from '@/composables/useCompanies' |
| 130 | +import { useAuth } from '@/composables/useAuth' | ||
| 126 | 131 | ||
| 127 | const route = useRoute() | 132 | const route = useRoute() |
| 128 | const router = useRouter() | 133 | const router = useRouter() |
| 129 | const { themeMode, cycleTheme } = useTheme() | 134 | const { themeMode, cycleTheme } = useTheme() |
| 130 | const { companies, loadCompanies, activeCompanyId, setActiveCompany } = useCompanies() | 135 | const { companies, loadCompanies, activeCompanyId, setActiveCompany } = useCompanies() |
| 136 | +const { user, logout } = useAuth() | ||
| 137 | + | ||
| 138 | +async function handleLogout() { | ||
| 139 | + await logout() | ||
| 140 | + router.push({ name: 'Login' }) | ||
| 141 | +} | ||
| 131 | 142 | ||
| 132 | const showDropdown = ref(false) | 143 | const showDropdown = ref(false) |
| 133 | const showNewCompanyModal = ref(false) | 144 | const showNewCompanyModal = ref(false) |
| @@ -256,6 +267,14 @@ async function submitNewCompany() { | @@ -256,6 +267,14 @@ async function submitNewCompany() { | ||
| 256 | .theme-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); } | 267 | .theme-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); } |
| 257 | .nav-version { font-size: 10px; color: var(--text-faint); padding: 2px 6px; border: 1px solid var(--border-primary); border-radius: 4px; font-weight:600;} | 268 | .nav-version { font-size: 10px; color: var(--text-faint); padding: 2px 6px; border: 1px solid var(--border-primary); border-radius: 4px; font-weight:600;} |
| 258 | 269 | ||
| 270 | +.nav-user { display: flex; align-items: center; gap: 8px; padding-left: 12px; border-left: 1px solid var(--border-primary); } | ||
| 271 | +.nav-user-name { font-size: 12px; color: var(--text-secondary); } | ||
| 272 | +.nav-logout-btn { | ||
| 273 | + font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border-primary); | ||
| 274 | + background: var(--bg-surface); color: var(--text-secondary); cursor: pointer; transition: all 0.2s; | ||
| 275 | +} | ||
| 276 | +.nav-logout-btn:hover { background: var(--bg-surface-hover); color: var(--text-primary); } | ||
| 277 | + | ||
| 259 | /* Modal */ | 278 | /* Modal */ |
| 260 | .nav-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 2000; backdrop-filter: blur(4px); } | 279 | .nav-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 2000; backdrop-filter: blur(4px); } |
| 261 | .nav-modal-content { background: var(--bg-primary); border-radius: 12px; width: 400px; padding: 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); border: 1px solid var(--border-primary); } | 280 | .nav-modal-content { background: var(--bg-primary); border-radius: 12px; width: 400px; padding: 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); border: 1px solid var(--border-primary); } |
web/src/composables/useAuth.js
0 → 100644
| 1 | +import { ref } from 'vue' | ||
| 2 | +import axios from 'axios' | ||
| 3 | + | ||
| 4 | +const user = ref(null) | ||
| 5 | +const isChecked = ref(false) // 是否已经问过一次 /api/auth/me,避免路由守卫重复请求 | ||
| 6 | +let checkPromise = null | ||
| 7 | + | ||
| 8 | +export function useAuth() { | ||
| 9 | + /** 查一次当前登录态,结果缓存到下次显式 refresh。未登录返回 null,不算错误。 */ | ||
| 10 | + async function checkAuth({ force = false } = {}) { | ||
| 11 | + if (isChecked.value && !force) return user.value | ||
| 12 | + if (checkPromise && !force) return checkPromise | ||
| 13 | + | ||
| 14 | + checkPromise = axios.get('/api/auth/me') | ||
| 15 | + .then(({ data }) => { | ||
| 16 | + user.value = data.user || null | ||
| 17 | + isChecked.value = true | ||
| 18 | + return user.value | ||
| 19 | + }) | ||
| 20 | + .catch(() => { | ||
| 21 | + user.value = null | ||
| 22 | + isChecked.value = true | ||
| 23 | + return null | ||
| 24 | + }) | ||
| 25 | + .finally(() => { checkPromise = null }) | ||
| 26 | + | ||
| 27 | + return checkPromise | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + async function login(email, password) { | ||
| 31 | + const { data } = await axios.post('/api/auth/login', { email, password }) | ||
| 32 | + user.value = data.user | ||
| 33 | + isChecked.value = true | ||
| 34 | + return data.user | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + async function logout() { | ||
| 38 | + try { await axios.post('/api/auth/logout') } catch { /* cookie 清不掉也要清本地状态 */ } | ||
| 39 | + user.value = null | ||
| 40 | + isChecked.value = true | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /** 401 拦截器触发时调用:本地登录态失效,不用再打一次 /me 去确认 */ | ||
| 44 | + function clearAuth() { | ||
| 45 | + user.value = null | ||
| 46 | + isChecked.value = true | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + return { user, isChecked, checkAuth, login, logout, clearAuth } | ||
| 50 | +} |
web/src/main.js
| 1 | import { createApp } from 'vue' | 1 | import { createApp } from 'vue' |
| 2 | +import axios from 'axios' | ||
| 2 | import App from './App.vue' | 3 | import App from './App.vue' |
| 3 | import router from './router' | 4 | import router from './router' |
| 5 | +import { useAuth } from './composables/useAuth' | ||
| 6 | + | ||
| 7 | +// 38 号文档 Phase 0:任意请求收到 401 → 本地登录态清掉、跳登录页。 | ||
| 8 | +// 覆盖「cookie 在中途过期」这类路由守卫放行之后才发生的情况,不止是页面切换那一刻。 | ||
| 9 | +axios.interceptors.response.use( | ||
| 10 | + (res) => res, | ||
| 11 | + (err) => { | ||
| 12 | + if (err.response?.status === 401 && router.currentRoute.value.name !== 'Login') { | ||
| 13 | + useAuth().clearAuth() | ||
| 14 | + router.push({ name: 'Login', query: { redirect: router.currentRoute.value.fullPath } }) | ||
| 15 | + } | ||
| 16 | + return Promise.reject(err) | ||
| 17 | + } | ||
| 18 | +) | ||
| 4 | 19 | ||
| 5 | const app = createApp(App) | 20 | const app = createApp(App) |
| 6 | app.use(router) | 21 | app.use(router) |
web/src/router/index.js
| 1 | import { createRouter, createWebHistory } from 'vue-router' | 1 | import { createRouter, createWebHistory } from 'vue-router' |
| 2 | +import { useAuth } from '@/composables/useAuth' | ||
| 2 | 3 | ||
| 3 | const routes = [ | 4 | const routes = [ |
| 4 | { | 5 | { |
| 6 | + path: '/login', | ||
| 7 | + name: 'Login', | ||
| 8 | + component: () => import('@/views/Login.vue'), | ||
| 9 | + }, | ||
| 10 | + { | ||
| 5 | path: '/', | 11 | path: '/', |
| 6 | redirect: '/agent/master' | 12 | redirect: '/agent/master' |
| 7 | }, | 13 | }, |
| @@ -109,4 +115,19 @@ const router = createRouter({ | @@ -109,4 +115,19 @@ const router = createRouter({ | ||
| 109 | routes, | 115 | routes, |
| 110 | }) | 116 | }) |
| 111 | 117 | ||
| 118 | +// Phase 0(38 号文档):未登录访问任何非登录页 → 跳登录页;已登录访问登录页 → 跳回首页 | ||
| 119 | +// (或 redirect query 指回的目标)。这一步还不做「你能不能看这个 company」的归属校验 | ||
| 120 | +// (Phase 2 才做),只判断「你登录了没有」。 | ||
| 121 | +router.beforeEach(async (to) => { | ||
| 122 | + if (to.name === 'Login') return true | ||
| 123 | + | ||
| 124 | + const { user, checkAuth } = useAuth() | ||
| 125 | + await checkAuth() | ||
| 126 | + | ||
| 127 | + if (!user.value) { | ||
| 128 | + return { name: 'Login', query: { redirect: to.fullPath } } | ||
| 129 | + } | ||
| 130 | + return true | ||
| 131 | +}) | ||
| 132 | + | ||
| 112 | export default router | 133 | export default router |
web/src/views/Login.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="login-page"> | ||
| 3 | + <form class="login-card" @submit.prevent="submit"> | ||
| 4 | + <div class="login-brand"> | ||
| 5 | + <span class="login-logo">🧬</span> | ||
| 6 | + <span class="login-title">LemonBoss</span> | ||
| 7 | + </div> | ||
| 8 | + | ||
| 9 | + <div class="form-group"> | ||
| 10 | + <label>账号</label> | ||
| 11 | + <input v-model="email" type="text" autocomplete="username" placeholder="账号" autofocus /> | ||
| 12 | + </div> | ||
| 13 | + <div class="form-group"> | ||
| 14 | + <label>密码</label> | ||
| 15 | + <input v-model="password" type="password" autocomplete="current-password" placeholder="密码" /> | ||
| 16 | + </div> | ||
| 17 | + | ||
| 18 | + <p v-if="error" class="login-error">{{ error }}</p> | ||
| 19 | + | ||
| 20 | + <button class="login-submit" type="submit" :disabled="loading || !email || !password"> | ||
| 21 | + {{ loading ? '登录中...' : '登录' }} | ||
| 22 | + </button> | ||
| 23 | + </form> | ||
| 24 | + </div> | ||
| 25 | +</template> | ||
| 26 | + | ||
| 27 | +<script setup> | ||
| 28 | +import { ref } from 'vue' | ||
| 29 | +import { useRoute, useRouter } from 'vue-router' | ||
| 30 | +import { useAuth } from '@/composables/useAuth' | ||
| 31 | + | ||
| 32 | +const route = useRoute() | ||
| 33 | +const router = useRouter() | ||
| 34 | +const { login } = useAuth() | ||
| 35 | + | ||
| 36 | +const email = ref('') | ||
| 37 | +const password = ref('') | ||
| 38 | +const error = ref('') | ||
| 39 | +const loading = ref(false) | ||
| 40 | + | ||
| 41 | +async function submit() { | ||
| 42 | + error.value = '' | ||
| 43 | + loading.value = true | ||
| 44 | + try { | ||
| 45 | + await login(email.value.trim(), password.value) | ||
| 46 | + const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/' | ||
| 47 | + router.replace(redirect) | ||
| 48 | + } catch (e) { | ||
| 49 | + error.value = e.response?.data?.error || '登录失败,请检查账号密码' | ||
| 50 | + } finally { | ||
| 51 | + loading.value = false | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | +</script> | ||
| 55 | + | ||
| 56 | +<style scoped> | ||
| 57 | +.login-page { | ||
| 58 | + min-height: 100vh; | ||
| 59 | + display: flex; | ||
| 60 | + align-items: center; | ||
| 61 | + justify-content: center; | ||
| 62 | + background: var(--bg-primary); | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +.login-card { | ||
| 66 | + width: 320px; | ||
| 67 | + padding: 32px; | ||
| 68 | + border-radius: 12px; | ||
| 69 | + background: var(--bg-secondary); | ||
| 70 | + border: 1px solid var(--border-primary); | ||
| 71 | + display: flex; | ||
| 72 | + flex-direction: column; | ||
| 73 | + gap: 16px; | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +.login-brand { | ||
| 77 | + display: flex; | ||
| 78 | + align-items: center; | ||
| 79 | + gap: 8px; | ||
| 80 | + justify-content: center; | ||
| 81 | + margin-bottom: 8px; | ||
| 82 | +} | ||
| 83 | +.login-logo { font-size: 24px; } | ||
| 84 | +.login-title { font-size: 18px; font-weight: 700; color: var(--text-primary); } | ||
| 85 | + | ||
| 86 | +.form-group { display: flex; flex-direction: column; gap: 6px; } | ||
| 87 | +.form-group label { font-size: 13px; color: var(--text-secondary); } | ||
| 88 | +.form-group input { | ||
| 89 | + padding: 10px 12px; | ||
| 90 | + border-radius: 8px; | ||
| 91 | + border: 1px solid var(--border-primary); | ||
| 92 | + background: var(--bg-input); | ||
| 93 | + color: var(--text-primary); | ||
| 94 | + font-size: 14px; | ||
| 95 | + outline: none; | ||
| 96 | +} | ||
| 97 | +.form-group input:focus { background: var(--bg-input-focus); border-color: var(--border-active); } | ||
| 98 | + | ||
| 99 | +.login-error { | ||
| 100 | + font-size: 13px; | ||
| 101 | + color: #f87171; | ||
| 102 | + margin: 0; | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +.login-submit { | ||
| 106 | + margin-top: 8px; | ||
| 107 | + padding: 10px; | ||
| 108 | + border: none; | ||
| 109 | + border-radius: 8px; | ||
| 110 | + background: var(--accent); | ||
| 111 | + color: #05201a; | ||
| 112 | + font-weight: 600; | ||
| 113 | + font-size: 14px; | ||
| 114 | + cursor: pointer; | ||
| 115 | +} | ||
| 116 | +.login-submit:disabled { opacity: 0.5; cursor: not-allowed; } | ||
| 117 | +.login-submit:not(:disabled):hover { background: var(--accent-light); } | ||
| 118 | +</style> |