oa-email-code-login 实现邮箱验证码登录
这个提交包含在:
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
## Source Of Truth
|
## Source Of Truth
|
||||||
|
|
||||||
UI work must read `docs/产品设计规则.md` first and align with the Hyinda brand
|
Frontend visual and interaction design uses the installed `frontend-design`
|
||||||
style. For PC/mobile/WeCom pages, add Playwright or visual verification when
|
skill as the primary design authority. `docs/产品设计规则.md` remains a product,
|
||||||
reasonable.
|
brand and historical fact source, not the final aesthetic tie-breaker. For
|
||||||
|
PC/mobile/WeCom pages, add Playwright or visual verification when reasonable.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Use existing React/Vite/Tailwind patterns and shared components before adding
|
- Use existing React/Vite/Tailwind patterns and shared components before adding
|
||||||
page-local UI systems.
|
page-local UI systems.
|
||||||
|
- When `frontend-design` and older project visual rules conflict, follow
|
||||||
|
`frontend-design` for visual direction while preserving security, privacy,
|
||||||
|
Trellis, deployment and business data boundaries.
|
||||||
- PC pages should keep dense but orderly OA layouts: clear header, filters,
|
- PC pages should keep dense but orderly OA layouts: clear header, filters,
|
||||||
table/list, status, details and predictable actions.
|
table/list, status, details and predictable actions.
|
||||||
- Mobile pages must use the unified mobile shell, card/list rows, safe-area and
|
- Mobile pages must use the unified mobile shell, card/list rows, safe-area and
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Validate Trellis task structure, checks and commit readiness."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Verify backend auth route, Prisma migration and validation rules."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify auth UI hierarchy, PWA behavior and frontend checks."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/wecom-boundary.md", "reason": "Verify Enterprise WeChat login entry behavior remains bounded."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Verify no sensitive auth values are persisted or written to evidence."}
|
||||||
|
{"file": ".trellis/spec/backend/index.md", "reason": "Backend quality review entry point."}
|
||||||
|
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend quality review entry point."}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa Trellis spec entry point and read order."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Task contract, approvals, context curation and commit rules."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Backend Fastify/Prisma boundaries for new auth endpoints and migration."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Auth page layout, login hierarchy, PWA and frontend verification contracts."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/wecom-boundary.md", "reason": "Enterprise WeChat login entry must remain separate from email-code login."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Sensitive auth data, OTP and Trellis evidence handling."}
|
||||||
|
{"file": ".trellis/spec/backend/index.md", "reason": "Backend coding guideline entry point."}
|
||||||
|
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend React/Vite guideline entry point."}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 邮箱验证码登录实施记录
|
||||||
|
|
||||||
|
## Implementation Scope
|
||||||
|
|
||||||
|
- 新增 `EmailLoginCode` Prisma 模型和迁移,用于登录验证码,不复用 `PasswordResetCode`。
|
||||||
|
- 新增后端接口:
|
||||||
|
- `POST /api/auth/email-login/code`
|
||||||
|
- `POST /api/auth/email-login/verify`
|
||||||
|
- 邮箱验证码使用 HMAC-SHA256 保存 hash,验证码明文只进入邮件正文,不写库。
|
||||||
|
- 验证成功后复用现有 refresh cookie、`has_session`、access token、设备指纹和 session 数量上限逻辑。
|
||||||
|
- 前端登录页新增“密码登录 / 邮箱验证码”切换。
|
||||||
|
- 使用 `frontend-design` 作为前端视觉主准则,将认证页升级为 refined industrial minimal:细网格背景、顶部品牌轨道、单一认证面板。
|
||||||
|
|
||||||
|
## Safety Boundaries
|
||||||
|
|
||||||
|
- 不复用密码找回验证码表和语义。
|
||||||
|
- 不新增短信登录。
|
||||||
|
- 不修改企业微信 OAuth/绑定逻辑。
|
||||||
|
- 不修改企业邮箱/企业微信后台配置。
|
||||||
|
- 不写真实账号密码、验证码、token、Secret 或真实 payload。
|
||||||
|
- 不部署公网,不写真实业务数据。
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- 新增表:`email_login_codes`。
|
||||||
|
- 外键:`email_login_codes.employee_id -> employees.id`,`ON DELETE RESTRICT`。
|
||||||
|
- 索引:
|
||||||
|
- `email_login_codes_employee_id_created_at_idx`
|
||||||
|
- `email_login_codes_expires_at_idx`
|
||||||
|
- 回滚路径:若需回滚本功能,先停用新增前端入口和接口,再执行 `DROP TABLE "email_login_codes";`。该表只保存登录验证码 hash 和请求上下文,不承载业务事实源。
|
||||||
|
|
||||||
|
## Validation Log
|
||||||
|
|
||||||
|
- `cd server && node --check src/routes/auth.js && node --check src/config.js`:已通过。
|
||||||
|
- `cd server && npm run prisma:validate`:已通过。
|
||||||
|
- `cd server && npm test -- --run tests/unit/routes/auth.login.test.js`:已通过,9 passed。
|
||||||
|
- `cd client && npm run typecheck`:已通过。
|
||||||
|
- `cd client && npm run build`:已通过,production build 和 PWA precache 正常生成。
|
||||||
|
- 登录 focused E2E:已通过,3 passed。
|
||||||
|
- 企业微信入口 E2E:已通过,2 passed。
|
||||||
|
- `node scripts/qa/check-ui-design-rules.mjs --strict`:已通过。
|
||||||
|
- `python3 ./.trellis/scripts/task.py validate 05-26-oa-email-code-login`:已通过。
|
||||||
|
- `git diff --check`:已通过。
|
||||||
|
- 视觉截图已生成:
|
||||||
|
- `output/playwright/oa-email-code-login/desktop-password-login.png`
|
||||||
|
- `output/playwright/oa-email-code-login/desktop-email-code-login.png`
|
||||||
|
- `output/playwright/oa-email-code-login/mobile-wecom-login.png`
|
||||||
|
|
||||||
|
## Design Governance Update
|
||||||
|
|
||||||
|
- 已同步 `AGENTS.md`、`.trellis/spec/hyb-oa/frontend-react-vite-design.md` 和 `docs/产品设计规则.md`:前端视觉与交互设计以 `frontend-design` skill 为准。
|
||||||
|
- `docs/产品设计规则.md` 继续保留业务事实、品牌识别、历史页面治理和验收参考,不作为视觉裁决优先级。
|
||||||
|
|
||||||
|
## Known Notes
|
||||||
|
|
||||||
|
- Trellis governance 对历史任务 `05-26-oa-auth-layout-wecom-login-ux` 的 jsonl 仍保留 `docs/产品设计规则.md` context warning;这是已提交历史任务的上下文提醒,不影响本任务通过。
|
||||||
|
- 公网部署和 live smoke 需要本地提交后另行确认。
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 邮箱验证码登录
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在现有账号密码登录、企业微信登录、密码找回基础上,新增“邮箱验证码登录”。用户可通过企业邮箱接收 6 位验证码,在不输入密码的情况下登录 OA;同时保持验证码风控、审计、会话签发和现有登录体验一致。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 后端新增邮箱验证码登录接口、验证码持久化模型和安全校验。
|
||||||
|
- 前端登录页新增“密码登录 / 邮箱验证码”方式切换。
|
||||||
|
- 复用现有 SMTP 邮件服务,兼容企业微信里的企业邮箱。
|
||||||
|
- 验证成功后复用现有 access token、refresh cookie、`has_session` 和设备指纹会话逻辑。
|
||||||
|
- 增加单元测试、前端 E2E 和视觉截图证据。
|
||||||
|
|
||||||
|
## What I Already Know
|
||||||
|
|
||||||
|
- 用户已确认实现邮箱验证码登录,并要求前端设计优先使用 `frontend-design` skill。
|
||||||
|
- `frontend-design` 已安装到 `/home/shunli/.codex/skills/frontend-design`,当前会话已读取其规则。
|
||||||
|
- 当前登录页已有共享 `AuthPanel`、账号密码登录、企业微信入口、找回密码入口和 PWA 更新保护。
|
||||||
|
- 后端已有 `PasswordResetCode`,仅用于密码找回,不能直接复用为登录验证码。
|
||||||
|
- 后端已有 `sendNotificationEmailSafe` + SMTP 配置,企业邮箱可通过 SMTP 发验证码。
|
||||||
|
- 现有 `/api/auth/login` 已实现设备指纹校验、登录失败锁定、session 数量上限、refresh cookie、`has_session`、审计日志和企业微信待绑定处理。
|
||||||
|
|
||||||
|
## Design Direction
|
||||||
|
|
||||||
|
- 采用 `frontend-design` 的“production-grade、精致、有明确审美方向”要求。
|
||||||
|
- 本项目登录页方向定为“refined industrial minimal”:克制、干净、可信、精密,不做营销式大图和复杂装饰。
|
||||||
|
- 前端视觉和界面设计以 `frontend-design` skill 为准;旧有前端设计文档不再作为审美裁决依据。
|
||||||
|
- 旧有项目文档仅保留业务事实、品牌识别、验收命令和安全边界参考;与 `frontend-design` 的视觉建议冲突时,采用 `frontend-design`。
|
||||||
|
- 安全、隐私、Trellis、生产部署和真实数据边界不属于前端审美规范,仍按项目强约束执行。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 新增登录验证码数据模型,字段至少包括员工、验证码 hash、过期时间、使用时间、尝试次数、创建 IP、创建 UA。
|
||||||
|
- 请求验证码接口:
|
||||||
|
- `POST /api/auth/email-login/code`
|
||||||
|
- 入参:`email`
|
||||||
|
- 无论账号是否存在或是否可用,返回通用成功,避免枚举账号。
|
||||||
|
- 对有效在职账号发送邮件验证码。
|
||||||
|
- 对同一邮箱/IP 使用现有登录限流,并在模型层记录验证码请求上下文。
|
||||||
|
- 校验登录接口:
|
||||||
|
- `POST /api/auth/email-login/verify`
|
||||||
|
- 入参:`email`、`code`
|
||||||
|
- 请求头必须包含 `x-device-fingerprint`。
|
||||||
|
- 验证码必须未过期、未使用、hash 匹配、尝试次数未超限。
|
||||||
|
- 成功后标记验证码已使用,创建 auth session,写 refresh cookie 和 `has_session`,返回 `LoginResponse`。
|
||||||
|
- 成功后清理员工登录失败锁定状态;如 `must_change_password=true`,仍返回该状态让前端跳转修改密码。
|
||||||
|
- 前端登录页:
|
||||||
|
- 普通浏览器下,在主登录区提供“密码登录 / 邮箱验证码”切换。
|
||||||
|
- 邮箱验证码登录包含邮箱输入、发送验证码、验证码输入、登录按钮。
|
||||||
|
- 发送验证码后保留邮箱和验证码输入,不自动刷新,不持久化验证码。
|
||||||
|
- 企业微信入口仍位于次级登录区;企业微信 WebView 下企业微信一键进入仍为主 CTA。
|
||||||
|
- 邮件内容:
|
||||||
|
- 标题清楚标识 OA 登录验证码。
|
||||||
|
- 正文只包含验证码、有效期和非本人操作提醒。
|
||||||
|
- 不写入密码、token、完整敏感 payload。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] 普通浏览器登录页可切换密码登录和邮箱验证码登录。
|
||||||
|
- [ ] 邮箱验证码请求成功时显示通用提示,不暴露账号是否存在。
|
||||||
|
- [ ] 正确验证码可完成登录并进入原 redirect 目标。
|
||||||
|
- [ ] 错误、过期或已使用验证码不能登录,并有可理解错误提示。
|
||||||
|
- [ ] 企业微信 WebView 登录层级不回退,企微一键进入仍为主入口。
|
||||||
|
- [ ] 找回密码验证码和登录验证码数据、接口、审计语义隔离。
|
||||||
|
- [ ] 前端不持久化验证码、密码或 token。
|
||||||
|
- [ ] 后端单测、前端 typecheck/build、关键 E2E 和设计规则检查通过。
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- Prisma schema/migration 可验证。
|
||||||
|
- `server` 相关 auth 单测通过。
|
||||||
|
- `client` typecheck 和 build 通过。
|
||||||
|
- Playwright 覆盖密码登录、邮箱验证码登录、找回密码、企业微信入口。
|
||||||
|
- 视觉截图覆盖 PC 登录页和移动/企微登录页。
|
||||||
|
- Trellis validate、governance、`git diff --check` 通过。
|
||||||
|
- 本地通过后再申请部署和公网 smoke;未部署前不告知用户可公网测试。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 不接入企业微信通讯录邮箱 API 或专业版企业邮箱 API。
|
||||||
|
- 不新增短信登录。
|
||||||
|
- 不修改企业微信 OAuth/绑定后端逻辑。
|
||||||
|
- 不修改真实企业微信后台配置。
|
||||||
|
- 不执行真实账号重置、真实业务写入或公网部署。
|
||||||
|
- 不改变密码找回流程。
|
||||||
|
|
||||||
|
## Proposed Implementation
|
||||||
|
|
||||||
|
1. Backend schema:
|
||||||
|
- 新增 `EmailLoginCode` 模型和迁移。
|
||||||
|
- 员工表增加 relation。
|
||||||
|
2. Backend route:
|
||||||
|
- 在 `server/src/routes/auth.js` 新增请求验证码和校验登录接口。
|
||||||
|
- 抽取或复用 session 创建逻辑,确保邮箱验证码登录和密码登录会话行为一致。
|
||||||
|
- 增加审计 action:`AUTH_EMAIL_LOGIN_CODE_REQUESTED`、`AUTH_EMAIL_LOGIN_SUCCESS`、`AUTH_EMAIL_LOGIN_FAILED`。
|
||||||
|
3. Config:
|
||||||
|
- 新增邮箱登录验证码 TTL 和最大尝试次数配置,默认 10 分钟、5 次。
|
||||||
|
4. Frontend API:
|
||||||
|
- 在 `client/src/api/auth.ts` 增加请求验证码和校验登录函数。
|
||||||
|
5. Frontend UI:
|
||||||
|
- `LoginPage` 增加 segmented login mode。
|
||||||
|
- 保持 `AuthPanel`,按 `frontend-design` 做更精致的单面板交互、状态提示和间距。
|
||||||
|
6. Tests:
|
||||||
|
- 后端单测覆盖请求验证码、账号枚举保护、正确验证码登录、错误验证码失败、重复使用失败。
|
||||||
|
- 前端 E2E 覆盖邮箱验证码登录路径和现有登录入口不回退。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm run prisma:validate
|
||||||
|
cd server && npm test -- --run tests/unit/routes/auth.login.test.js
|
||||||
|
cd client && npm run typecheck
|
||||||
|
cd client && npm run build
|
||||||
|
E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts -g "邮箱验证码登录|登录页提供找回密码|找回密码流程" --workers=1 --reporter=list
|
||||||
|
E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/wecom-entry.spec.ts --workers=1 --reporter=list
|
||||||
|
node scripts/qa/check-ui-design-rules.mjs --strict
|
||||||
|
python3 ./.trellis/scripts/task.py validate 05-26-oa-email-code-login
|
||||||
|
node scripts/qa/check-trellis-governance.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- Existing auth route: `server/src/routes/auth.js`
|
||||||
|
- Existing email service: `server/src/services/email/index.js`
|
||||||
|
- Existing password reset model: `server/prisma/schema.prisma`
|
||||||
|
- Existing frontend login: `client/src/pages/LoginPage.tsx`
|
||||||
|
- Existing auth API client: `client/src/api/auth.ts`
|
||||||
|
- Existing auth E2E: `client/tests/e2e/login-portal-ux.spec.ts`
|
||||||
|
- Frontend design skill: `/home/shunli/.codex/skills/frontend-design/SKILL.md`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"id": "oa-email-code-login",
|
||||||
|
"name": "oa-email-code-login",
|
||||||
|
"title": "邮箱验证码登录",
|
||||||
|
"description": "新增邮箱验证码登录:独立登录验证码模型和接口,复用现有 SMTP 邮件服务、会话签发与审计;前端登录页增加密码登录/邮箱验证码切换,并按 frontend-design 做认证页视觉升级。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": "server+client/auth-email-code-login",
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-05-26",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [
|
||||||
|
"server/prisma/schema.prisma",
|
||||||
|
"server/src/config.js",
|
||||||
|
"server/src/routes/auth.js",
|
||||||
|
"server/tests/unit/routes/auth.login.test.js",
|
||||||
|
"client/src/api/auth.ts",
|
||||||
|
"client/src/api/errorMessages.ts",
|
||||||
|
"client/src/auth/AuthContext.tsx",
|
||||||
|
"client/src/auth/authService.ts",
|
||||||
|
"client/src/components/auth/AuthPanel.tsx",
|
||||||
|
"client/src/layouts/AuthLayout.tsx",
|
||||||
|
"client/src/pages/LoginPage.tsx",
|
||||||
|
"client/tests/e2e/login-portal-ux.spec.ts"
|
||||||
|
],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
- 中文沟通;commit message 使用中文并引用 Trellis task 名称或任务 ID。
|
- 中文沟通;commit message 使用中文并引用 Trellis task 名称或任务 ID。
|
||||||
- 不泄露密码、token、Secret、真实票据、合同正文、银行资料、完整账号、完整本地路径或原始 payload。
|
- 不泄露密码、token、Secret、真实票据、合同正文、银行资料、完整账号、完整本地路径或原始 payload。
|
||||||
- 不轻易新建文档;优先更新现有事实源、模块规约、操作手册、证据文档或当前 Trellis task。
|
- 不轻易新建文档;优先更新现有事实源、模块规约、操作手册、证据文档或当前 Trellis task。
|
||||||
- 页面调整必须参考 `docs/产品设计规则.md`,并对齐公司主页 `www.hyinda.com` 的品牌风格。
|
- 页面视觉与交互设计以 `frontend-design` skill 为准;`docs/产品设计规则.md` 仅保留业务事实、品牌识别和验收参考。
|
||||||
- 面向用户可测试的改动,只有部署与公网检查通过后才能告知用户可以测试。
|
- 面向用户可测试的改动,只有部署与公网检查通过后才能告知用户可以测试。
|
||||||
|
|
||||||
## 当前项目事实源
|
## 当前项目事实源
|
||||||
|
|
||||||
- 文档入口与状态:`docs/文档索引.md`、`docs/文档事实源状态矩阵.md`
|
- 文档入口与状态:`docs/文档索引.md`、`docs/文档事实源状态矩阵.md`
|
||||||
- Agent/Trellis 交付实践:`docs/Agent开发交付实践.md`
|
- Agent/Trellis 交付实践:`docs/Agent开发交付实践.md`
|
||||||
- 产品与视觉规则:`docs/产品设计规则.md`
|
- 产品/品牌事实与历史视觉规则:`docs/产品设计规则.md`(视觉裁决以 `frontend-design` skill 为准)
|
||||||
- 报销、发票、OCR、附件规则:`docs/报销发票附件规则.md`
|
- 报销、发票、OCR、附件规则:`docs/报销发票附件规则.md`
|
||||||
- 试运行与自动化验证:`docs/试运行与自动化验证手册.md`
|
- 试运行与自动化验证:`docs/试运行与自动化验证手册.md`
|
||||||
- Trellis workflow:`.trellis/workflow.md`
|
- Trellis workflow:`.trellis/workflow.md`
|
||||||
@@ -52,4 +52,4 @@
|
|||||||
- 默认按 Trellis workflow 执行:task/prd -> design/implement notes(按需) -> implement -> check -> spec/docs update -> commit -> finish。
|
- 默认按 Trellis workflow 执行:task/prd -> design/implement notes(按需) -> implement -> check -> spec/docs update -> commit -> finish。
|
||||||
- 涉及权限模型、状态机、workflow、数据迁移、审批规则时,实施后必须启动只读 review,不让 reviewer 改代码。
|
- 涉及权限模型、状态机、workflow、数据迁移、审批规则时,实施后必须启动只读 review,不让 reviewer 改代码。
|
||||||
- 公网部署、生产配置、真实数据写入、数据库重置、企业微信写入和真实付款必须另行获得明确批准。
|
- 公网部署、生产配置、真实数据写入、数据库重置、企业微信写入和真实付款必须另行获得明确批准。
|
||||||
- 若 Trellis 规则与业务文档冲突:当前 Trellis task PRD/设计 > 当前模块规约 > `docs/产品设计规则.md` 和业务规约 > Trellis spec > 历史证据;历史 Issue CSV 只用于追溯。
|
- 若 Trellis 规则与业务文档冲突:当前 Trellis task PRD/设计 > 当前模块规约 > 业务规约和 `docs/产品设计规则.md` 中的事实源内容 > Trellis spec > 历史证据;前端视觉裁决优先使用 `frontend-design` skill,历史 Issue CSV 只用于追溯。
|
||||||
|
|||||||
@@ -35,6 +35,28 @@ export async function resetPassword(payload: { email: string; code: string; newP
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestEmailLoginCode(payload: { email: string }) {
|
||||||
|
return apiRequestData<Record<string, never>>("/api/auth/email-login/code", {
|
||||||
|
method: "POST",
|
||||||
|
auth: false,
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmailLoginCode(payload: { email: string; code: string }) {
|
||||||
|
return apiRequestData<LoginResponse>("/api/auth/email-login/verify", {
|
||||||
|
method: "POST",
|
||||||
|
auth: false,
|
||||||
|
retryOn401: false,
|
||||||
|
headers: {
|
||||||
|
...JSON_HEADERS,
|
||||||
|
"x-device-fingerprint": getDeviceFingerprint(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
||||||
|
|
||||||
export type WecomEntryConfig = {
|
export type WecomEntryConfig = {
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ export function mapApiErrorMessage(
|
|||||||
return "当前设备校验信息缺失,请刷新页面后重试。";
|
return "当前设备校验信息缺失,请刷新页面后重试。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (details?.code === "invalid_or_expired") {
|
||||||
|
return "验证码无效或已过期,请重新获取。";
|
||||||
|
}
|
||||||
|
|
||||||
if (details?.payment === "already_paid_conflict") {
|
if (details?.payment === "already_paid_conflict") {
|
||||||
return "该报销单已支付且付款信息冲突,请刷新后确认。";
|
return "该报销单已支付且付款信息冲突,请刷新后确认。";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { changePassword as changePasswordApi } from "../api/auth";
|
|||||||
import { clearPwaRuntimeCaches } from "../pwa";
|
import { clearPwaRuntimeCaches } from "../pwa";
|
||||||
import {
|
import {
|
||||||
fetchCurrentEmployee,
|
fetchCurrentEmployee,
|
||||||
|
loginWithEmailCode as loginWithEmailCodeRequest,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
logoutSession,
|
logoutSession,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
@@ -37,6 +38,7 @@ type OfflineAuthSnapshot = {
|
|||||||
|
|
||||||
type AuthContextValue = AuthState & {
|
type AuthContextValue = AuthState & {
|
||||||
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
|
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
|
||||||
|
loginWithEmailCode: (email: string, code: string) => Promise<{ mustChangePassword: boolean }>;
|
||||||
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
bootstrapByRefresh: () => Promise<void>;
|
bootstrapByRefresh: () => Promise<void>;
|
||||||
@@ -426,6 +428,18 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
[commitAuthenticatedUser, scheduleProactiveRefresh],
|
[commitAuthenticatedUser, scheduleProactiveRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loginWithEmailCode = useCallback(
|
||||||
|
async (email: string, code: string) => {
|
||||||
|
const result = await loginWithEmailCodeRequest({ email, code });
|
||||||
|
const mustChangePassword = Boolean(result.mustChangePassword);
|
||||||
|
const me = await fetchCurrentEmployee(result.accessToken);
|
||||||
|
commitAuthenticatedUser(result.accessToken, me, mustChangePassword);
|
||||||
|
scheduleProactiveRefresh(result.expiresIn);
|
||||||
|
return { mustChangePassword };
|
||||||
|
},
|
||||||
|
[commitAuthenticatedUser, scheduleProactiveRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
const changePassword = useCallback(
|
const changePassword = useCallback(
|
||||||
async (oldPassword: string, newPassword: string) => {
|
async (oldPassword: string, newPassword: string) => {
|
||||||
const result = await changePasswordApi({ oldPassword, newPassword });
|
const result = await changePasswordApi({ oldPassword, newPassword });
|
||||||
@@ -525,6 +539,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
login,
|
login,
|
||||||
|
loginWithEmailCode,
|
||||||
changePassword,
|
changePassword,
|
||||||
logout,
|
logout,
|
||||||
bootstrapByRefresh,
|
bootstrapByRefresh,
|
||||||
@@ -532,7 +547,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
clearSession,
|
clearSession,
|
||||||
offlineAccessMode,
|
offlineAccessMode,
|
||||||
}),
|
}),
|
||||||
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state],
|
[bootstrapByRefresh, changePassword, clearSession, login, loginWithEmailCode, logout, offlineAccessMode, refreshMe, state],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiRequestData } from "../api/http";
|
import { apiRequestData } from "../api/http";
|
||||||
|
import { verifyEmailLoginCode } from "../api/auth";
|
||||||
import type { LoginRequest, LoginResponse, MeResponse } from "../api/types";
|
import type { LoginRequest, LoginResponse, MeResponse } from "../api/types";
|
||||||
import { getDeviceFingerprint } from "./deviceFingerprint";
|
import { getDeviceFingerprint } from "./deviceFingerprint";
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ export async function loginWithPassword(payload: LoginRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loginWithEmailCode(payload: { email: string; code: string }) {
|
||||||
|
resetRefreshSessionCache();
|
||||||
|
return verifyEmailLoginCode(payload);
|
||||||
|
}
|
||||||
|
|
||||||
async function requestRefreshSession() {
|
async function requestRefreshSession() {
|
||||||
return apiRequestData<LoginResponse>("/api/auth/refresh", {
|
return apiRequestData<LoginResponse>("/api/auth/refresh", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ type AuthPanelProps = {
|
|||||||
export function AuthPanel({ title, subtitle, children, footer, className, "data-testid": testId }: AuthPanelProps) {
|
export function AuthPanel({ title, subtitle, children, footer, className, "data-testid": testId }: AuthPanelProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn("w-full rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8", className)}
|
className={cn(
|
||||||
|
"relative w-full overflow-hidden rounded-2xl border border-slate-200/80 bg-white/95 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.10)] ring-1 ring-white/70 backdrop-blur sm:p-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
|
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<AppBrand compact />
|
<AppBrand compact />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ export function AuthLayout() {
|
|||||||
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
|
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-bg-canvas)] px-4 py-8 sm:px-6 lg:px-10">
|
<div className="relative min-h-screen overflow-hidden bg-[#f4f7fa] px-4 py-8 sm:px-6 lg:px-10">
|
||||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-70"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px), linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "44px 44px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
|
||||||
|
<div className="relative mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
|
||||||
{updateReady ? (
|
{updateReady ? (
|
||||||
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
|
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
|
||||||
<div className="font-medium text-slate-900">系统有新版本</div>
|
<div className="font-medium text-slate-900">系统有新版本</div>
|
||||||
|
|||||||
+194
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
import { fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type WecomNotice,
|
type WecomNotice,
|
||||||
} from "../lib/wecomEntry";
|
} from "../lib/wecomEntry";
|
||||||
|
|
||||||
|
type LoginMode = "password" | "email_code";
|
||||||
|
|
||||||
function resolveRedirect(searchParams: URLSearchParams, fallback: string) {
|
function resolveRedirect(searchParams: URLSearchParams, fallback: string) {
|
||||||
const redirect = searchParams.get("redirect");
|
const redirect = searchParams.get("redirect");
|
||||||
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
|
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
|
||||||
@@ -40,12 +42,21 @@ function noticeClasses(tone: WecomNotice["tone"]) {
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { login } = useAuth();
|
const { login, loginWithEmailCode } = useAuth();
|
||||||
|
|
||||||
|
const [loginMode, setLoginMode] = useState<LoginMode>("password");
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [emailLoginEmail, setEmailLoginEmail] = useState("");
|
||||||
|
const [emailLoginCode, setEmailLoginCode] = useState("");
|
||||||
|
const [emailCodeRequested, setEmailCodeRequested] = useState(false);
|
||||||
|
const [emailCodeCooldown, setEmailCodeCooldown] = useState(0);
|
||||||
|
const [emailCodeSubmitting, setEmailCodeSubmitting] = useState(false);
|
||||||
|
const [emailCodeSending, setEmailCodeSending] = useState(false);
|
||||||
|
const [emailCodeMessage, setEmailCodeMessage] = useState("");
|
||||||
|
const [emailCodeError, setEmailCodeError] = useState("");
|
||||||
const [passwordFallbackOpen, setPasswordFallbackOpen] = useState(false);
|
const [passwordFallbackOpen, setPasswordFallbackOpen] = useState(false);
|
||||||
|
|
||||||
const isWecomContext = isWecomWebView();
|
const isWecomContext = isWecomWebView();
|
||||||
@@ -75,6 +86,18 @@ export function LoginPage() {
|
|||||||
const showWecomPrimary = isWecomContext;
|
const showWecomPrimary = isWecomContext;
|
||||||
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emailCodeCooldown <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setEmailCodeCooldown((current) => Math.max(0, current - 1));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [emailCodeCooldown]);
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -107,6 +130,79 @@ export function LoginPage() {
|
|||||||
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
|
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRequestEmailCode(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setEmailCodeError("");
|
||||||
|
setEmailCodeMessage("");
|
||||||
|
|
||||||
|
const email = emailLoginEmail.trim();
|
||||||
|
if (!email) {
|
||||||
|
setEmailCodeError("请输入企业邮箱。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailCodeSending(true);
|
||||||
|
try {
|
||||||
|
await requestEmailLoginCode({ email });
|
||||||
|
setEmailCodeRequested(true);
|
||||||
|
setEmailCodeCooldown(60);
|
||||||
|
setEmailCodeMessage("如账号可用,验证码将发送到该邮箱。");
|
||||||
|
} catch (err) {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||||
|
} else if (err instanceof TypeError) {
|
||||||
|
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
|
||||||
|
} else {
|
||||||
|
setEmailCodeError("发送验证码失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEmailCodeSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEmailCodeLogin(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setEmailCodeError("");
|
||||||
|
|
||||||
|
const email = emailLoginEmail.trim();
|
||||||
|
const code = emailLoginCode.trim();
|
||||||
|
if (!email) {
|
||||||
|
setEmailCodeError("请输入企业邮箱。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code.length !== 6) {
|
||||||
|
setEmailCodeError("请输入 6 位验证码。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailCodeSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await loginWithEmailCode(email, code);
|
||||||
|
if (result.mustChangePassword) {
|
||||||
|
navigate("/change-password", { replace: true });
|
||||||
|
} else {
|
||||||
|
navigate(redirectTo, { replace: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||||
|
} else if (err instanceof TypeError) {
|
||||||
|
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
|
||||||
|
} else {
|
||||||
|
setEmailCodeError("登录失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEmailCodeSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLoginMode(mode: LoginMode) {
|
||||||
|
setLoginMode(mode);
|
||||||
|
setError("");
|
||||||
|
setEmailCodeError("");
|
||||||
|
setEmailCodeMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
function renderPasswordPanel(testId: string) {
|
function renderPasswordPanel(testId: string) {
|
||||||
return (
|
return (
|
||||||
<div data-testid={testId}>
|
<div data-testid={testId}>
|
||||||
@@ -150,8 +246,76 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEmailCodePanel(testId: string) {
|
||||||
return (
|
return (
|
||||||
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : "账号密码登录"}>
|
<div className="space-y-4" data-testid={testId}>
|
||||||
|
<form className="space-y-4" onSubmit={handleRequestEmailCode}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="login-email-code-email">企业邮箱</Label>
|
||||||
|
<Input
|
||||||
|
autoComplete="email"
|
||||||
|
data-testid="login-email-code-email"
|
||||||
|
id="login-email-code-email"
|
||||||
|
onChange={(event) => setEmailLoginEmail(event.target.value)}
|
||||||
|
placeholder="请输入企业邮箱"
|
||||||
|
type="email"
|
||||||
|
value={emailLoginEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
data-testid="login-email-code-request"
|
||||||
|
disabled={emailCodeSending || emailCodeCooldown > 0 || !emailLoginEmail.trim()}
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{emailCodeSending ? "发送中..." : emailCodeCooldown > 0 ? `${emailCodeCooldown} 秒后重发` : emailCodeRequested ? "重新发送验证码" : "发送验证码"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleEmailCodeLogin}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="login-email-code">验证码</Label>
|
||||||
|
<Input
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
data-testid="login-email-code"
|
||||||
|
id="login-email-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(event) => setEmailLoginCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
value={emailLoginCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailCodeMessage ? (
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" data-testid="login-email-code-message">
|
||||||
|
{emailCodeMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{emailCodeError ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600" data-testid="login-email-code-error">
|
||||||
|
{emailCodeError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
data-testid="login-email-code-submit"
|
||||||
|
disabled={emailCodeSubmitting || !emailLoginEmail.trim() || emailLoginCode.length !== 6}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{emailCodeSubmitting ? "登录中..." : "验证码登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : loginMode === "email_code" ? "邮箱验证码登录" : "账号密码登录"}>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{wecomNotice ? (
|
{wecomNotice ? (
|
||||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||||
@@ -182,8 +346,33 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!showWecomPrimary ? (
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-xl border border-slate-200 bg-slate-100/80 p-1" data-testid="login-mode-switch">
|
||||||
|
<button
|
||||||
|
aria-pressed={loginMode === "password"}
|
||||||
|
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "password" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
|
||||||
|
data-testid="login-mode-password"
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectLoginMode("password")}
|
||||||
|
>
|
||||||
|
密码登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={loginMode === "email_code"}
|
||||||
|
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "email_code" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
|
||||||
|
data-testid="login-mode-email-code"
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectLoginMode("email_code")}
|
||||||
|
>
|
||||||
|
邮箱验证码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showPasswordPanel
|
{showPasswordPanel
|
||||||
? renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
? !showWecomPrimary && loginMode === "email_code"
|
||||||
|
? renderEmailCodePanel("login-email-code-panel")
|
||||||
|
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{!showWecomPrimary && (wecomEntry || oidcProvider) ? (
|
{!showWecomPrimary && (wecomEntry || oidcProvider) ? (
|
||||||
|
|||||||
@@ -350,6 +350,53 @@ test("登录页提供找回密码、密码显隐,PWA 更新不会清空正在
|
|||||||
await expect(page).toHaveURL(/\/forgot-password$/);
|
await expect(page).toHaveURL(/\/forgot-password$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("邮箱验证码登录可发送验证码并进入工作台", async ({ page }) => {
|
||||||
|
const codeRequests: unknown[] = [];
|
||||||
|
const verifyPayloads: unknown[] = [];
|
||||||
|
|
||||||
|
await mockGuestAuthChrome(page);
|
||||||
|
await mockMe(page, ["employee"], "Employee");
|
||||||
|
|
||||||
|
await page.route("**/api/auth/email-login/code", async (route) => {
|
||||||
|
codeRequests.push(route.request().postDataJSON());
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope({})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/auth/email-login/verify", async (route) => {
|
||||||
|
verifyPayloads.push(route.request().postDataJSON());
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope({
|
||||||
|
accessToken: "email-code-access-token",
|
||||||
|
expiresIn: 900,
|
||||||
|
mustChangePassword: false,
|
||||||
|
})),
|
||||||
|
headers: {
|
||||||
|
"set-cookie": "has_session=1; Path=/; SameSite=Strict",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/login?redirect=%2Fportal");
|
||||||
|
await page.getByTestId("login-mode-email-code").click();
|
||||||
|
await expect(page.getByTestId("login-email-code-panel")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("login-email-code-email").fill("[email protected]");
|
||||||
|
await page.getByTestId("login-email-code-request").click();
|
||||||
|
await expect(page.getByTestId("login-email-code-message")).toContainText("如账号可用");
|
||||||
|
expect(codeRequests).toEqual([{ email: "[email protected]" }]);
|
||||||
|
|
||||||
|
await page.getByTestId("login-email-code").fill("654321");
|
||||||
|
await page.getByTestId("login-email-code-submit").click();
|
||||||
|
await expect(page).toHaveURL(/\/portal$/);
|
||||||
|
expect(verifyPayloads).toEqual([{ email: "[email protected]", code: "654321" }]);
|
||||||
|
});
|
||||||
|
|
||||||
test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => {
|
test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => {
|
||||||
const requestedEmails: string[] = [];
|
const requestedEmails: string[] = [];
|
||||||
const resetPayloads: unknown[] = [];
|
const resetPayloads: unknown[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 产品设计规则基线
|
# 产品设计规则基线
|
||||||
|
|
||||||
|
> 2026-05-26 更新:前端视觉与交互设计的最终裁决迁移到 `frontend-design` skill。本文件继续作为 hyb-oa 的业务事实、品牌识别、历史页面治理和验收参考;当本文旧视觉规则与 `frontend-design` 冲突时,以 `frontend-design` 为准,同时不得覆盖安全、隐私、生产部署和真实数据边界。
|
||||||
|
|
||||||
本文件是 PC 网页和企业微信/手机网页统一优化的当前事实源。目标用户是 10 人以下的国内科技公司:使用频次高、角色重叠、审批链短,系统应当像工作台,不像宣传站,也不应像重型 ERP。
|
本文件是 PC 网页和企业微信/手机网页统一优化的当前事实源。目标用户是 10 人以下的国内科技公司:使用频次高、角色重叠、审批链短,系统应当像工作台,不像宣传站,也不应像重型 ERP。
|
||||||
|
|
||||||
## 设计目标
|
## 设计目标
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE "email_login_codes" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"employee_id" UUID NOT NULL,
|
||||||
|
"code_hash" TEXT NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"used_at" TIMESTAMP(3),
|
||||||
|
"attempts_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_ip" TEXT,
|
||||||
|
"created_user_agent" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "email_login_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "email_login_codes_employee_id_created_at_idx"
|
||||||
|
ON "email_login_codes"("employee_id", "created_at");
|
||||||
|
|
||||||
|
CREATE INDEX "email_login_codes_expires_at_idx"
|
||||||
|
ON "email_login_codes"("expires_at");
|
||||||
|
|
||||||
|
ALTER TABLE "email_login_codes"
|
||||||
|
ADD CONSTRAINT "email_login_codes_employee_id_fkey"
|
||||||
|
FOREIGN KEY ("employee_id") REFERENCES "employees"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -680,6 +680,7 @@ model Employee {
|
|||||||
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
|
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
|
||||||
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
|
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
|
||||||
password_reset_codes PasswordResetCode[]
|
password_reset_codes PasswordResetCode[]
|
||||||
|
email_login_codes EmailLoginCode[]
|
||||||
lifecycle_events EmployeeLifecycleEvent[]
|
lifecycle_events EmployeeLifecycleEvent[]
|
||||||
checklist_tasks EmployeeChecklistTask[]
|
checklist_tasks EmployeeChecklistTask[]
|
||||||
presence_records EmployeePresence[]
|
presence_records EmployeePresence[]
|
||||||
@@ -731,6 +732,24 @@ model PasswordResetCode {
|
|||||||
@@map("password_reset_codes")
|
@@map("password_reset_codes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EmailLoginCode {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
employee_id String @db.Uuid
|
||||||
|
code_hash String
|
||||||
|
expires_at DateTime
|
||||||
|
used_at DateTime?
|
||||||
|
attempts_count Int @default(0)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
created_ip String?
|
||||||
|
created_user_agent String?
|
||||||
|
|
||||||
|
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([employee_id, created_at])
|
||||||
|
@@index([expires_at])
|
||||||
|
@@map("email_login_codes")
|
||||||
|
}
|
||||||
|
|
||||||
model EmployeeRole {
|
model EmployeeRole {
|
||||||
employee_id String @db.Uuid
|
employee_id String @db.Uuid
|
||||||
role_code RoleCode
|
role_code RoleCode
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ function getConfig() {
|
|||||||
passwordResetCodeTtlSeconds: Number(
|
passwordResetCodeTtlSeconds: Number(
|
||||||
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
|
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
|
||||||
),
|
),
|
||||||
|
emailLoginCodeTtlSeconds: Number(
|
||||||
|
process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * 60,
|
||||||
|
),
|
||||||
|
emailLoginCodeMaxAttempts: Number(process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5),
|
||||||
|
|
||||||
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
|
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
|
||||||
|
|
||||||
|
|||||||
+258
-30
@@ -228,11 +228,69 @@ function parseAccessTokenTtlSeconds(value) {
|
|||||||
return amount * secondsByUnit[unit];
|
return amount * secondsByUnit[unit];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSixDigitCode() {
|
||||||
|
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashEmailLoginCode(employeeId, code, secret) {
|
||||||
|
return crypto.createHmac("sha256", secret).update(`${employeeId}:${code}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailLoginCodeError() {
|
||||||
|
return new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { code: "invalid_or_expired" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFingerprint, request, now, expiresAt }) {
|
||||||
|
const activeSessions = await tx.authSession.findMany({
|
||||||
|
where: {
|
||||||
|
employee_id: employeeId,
|
||||||
|
revoked_at: null,
|
||||||
|
expires_at: { gt: now },
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxSessions = 5;
|
||||||
|
const allowedExisting = maxSessions - 1;
|
||||||
|
if (activeSessions.length > allowedExisting) {
|
||||||
|
const revokeCount = activeSessions.length - allowedExisting;
|
||||||
|
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
|
||||||
|
await tx.authSession.updateMany({
|
||||||
|
where: { id: { in: idsToRevoke } },
|
||||||
|
data: { revoked_at: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.authSession.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employeeId,
|
||||||
|
refresh_token_hash: refreshTokenHash,
|
||||||
|
device_fingerprint: deviceFingerprint,
|
||||||
|
ip_address: request.ip,
|
||||||
|
user_agent: getHeaderString(request, "user-agent") ?? "",
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function authRoutes(fastify) {
|
async function authRoutes(fastify) {
|
||||||
const loginBodySchema = z.object({
|
const loginBodySchema = z.object({
|
||||||
identifier: z.string().min(3),
|
identifier: z.string().min(3),
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const emailLoginCodeRequestBodySchema = z.object({
|
||||||
|
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||||
|
});
|
||||||
|
const emailLoginVerifyBodySchema = z.object({
|
||||||
|
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||||
|
code: z.string().regex(/^\d{6}$/),
|
||||||
|
});
|
||||||
|
|
||||||
const oidcCookieName = "oidc_login";
|
const oidcCookieName = "oidc_login";
|
||||||
|
|
||||||
@@ -409,36 +467,13 @@ async function authRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeSessions = await tx.authSession.findMany({
|
await createRefreshSession(tx, {
|
||||||
where: {
|
employeeId: employee.id,
|
||||||
employee_id: employee.id,
|
refreshTokenHash,
|
||||||
revoked_at: null,
|
deviceFingerprint,
|
||||||
expires_at: { gt: now },
|
request,
|
||||||
},
|
now,
|
||||||
orderBy: { created_at: "asc" },
|
expiresAt,
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxSessions = 5;
|
|
||||||
const allowedExisting = maxSessions - 1;
|
|
||||||
if (activeSessions.length > allowedExisting) {
|
|
||||||
const revokeCount = activeSessions.length - allowedExisting;
|
|
||||||
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
|
|
||||||
await tx.authSession.updateMany({
|
|
||||||
where: { id: { in: idsToRevoke } },
|
|
||||||
data: { revoked_at: now },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.authSession.create({
|
|
||||||
data: {
|
|
||||||
employee_id: employee.id,
|
|
||||||
refresh_token_hash: refreshTokenHash,
|
|
||||||
device_fingerprint: deviceFingerprint,
|
|
||||||
ip_address: request.ip,
|
|
||||||
user_agent: getHeaderString(request, "user-agent") ?? "",
|
|
||||||
expires_at: expiresAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pendingWecomBind.ok) {
|
if (pendingWecomBind.ok) {
|
||||||
@@ -528,6 +563,199 @@ async function authRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post("/email-login/code", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
|
const parsed = emailLoginCodeRequestBodySchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = parsed.data;
|
||||||
|
const employee = await fastify.prisma.employee.findFirst({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employee || employee.status !== "active") {
|
||||||
|
return sendData(reply, request, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateSixDigitCode();
|
||||||
|
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
|
||||||
|
|
||||||
|
await fastify.prisma.emailLoginCode.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee.id,
|
||||||
|
code_hash: codeHash,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
created_ip: request.ip,
|
||||||
|
created_user_agent: getHeaderString(request, "user-agent"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: employee.id,
|
||||||
|
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: employee.id,
|
||||||
|
extra: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
void sendNotificationEmailSafe({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
logger: fastify.log,
|
||||||
|
requestId: request.id,
|
||||||
|
type: "auth.email_login_code_requested",
|
||||||
|
recipientEmployeeIds: [employee.id],
|
||||||
|
subject: "【安全】OA 登录验证码",
|
||||||
|
body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/email-login/verify", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
|
const parsed = emailLoginVerifyBodySchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
|
||||||
|
if (!deviceFingerprint) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { "x-device-fingerprint": "missing" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, code } = parsed.data;
|
||||||
|
const employee = await fastify.prisma.employee.findFirst({
|
||||||
|
where: { email },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
token_version: true,
|
||||||
|
must_change_password: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employee || employee.status !== "active") {
|
||||||
|
throw emailLoginCodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const loginCode = await fastify.prisma.emailLoginCode.findFirst({
|
||||||
|
where: {
|
||||||
|
employee_id: employee.id,
|
||||||
|
used_at: null,
|
||||||
|
expires_at: { gt: now },
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code_hash: true,
|
||||||
|
attempts_count: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginCode || loginCode.attempts_count >= fastify.config.emailLoginCodeMaxAttempts) {
|
||||||
|
throw emailLoginCodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCodeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
|
||||||
|
if (loginCode.code_hash !== expectedCodeHash) {
|
||||||
|
const nextAttempts = loginCode.attempts_count + 1;
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.emailLoginCode.update({
|
||||||
|
where: { id: loginCode.id },
|
||||||
|
data: { attempts_count: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: employee.id,
|
||||||
|
action: "AUTH_EMAIL_LOGIN_FAILED",
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: employee.id,
|
||||||
|
extra: { attemptsCount: nextAttempts },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
throw emailLoginCodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = randomRefreshToken();
|
||||||
|
const refreshTokenHash = sha256Hex(refreshToken);
|
||||||
|
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
|
||||||
|
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const used = await tx.emailLoginCode.updateMany({
|
||||||
|
where: { id: loginCode.id, used_at: null },
|
||||||
|
data: { used_at: now },
|
||||||
|
});
|
||||||
|
if (used.count !== 1) {
|
||||||
|
throw emailLoginCodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.employee.update({
|
||||||
|
where: { id: employee.id },
|
||||||
|
data: {
|
||||||
|
login_failed_count: 0,
|
||||||
|
login_failed_first_at: null,
|
||||||
|
login_locked_until: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createRefreshSession(tx, {
|
||||||
|
employeeId: employee.id,
|
||||||
|
refreshTokenHash,
|
||||||
|
deviceFingerprint,
|
||||||
|
request,
|
||||||
|
now,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: employee.id,
|
||||||
|
action: "AUTH_EMAIL_LOGIN_SUCCESS",
|
||||||
|
targetType: "employee",
|
||||||
|
targetId: employee.id,
|
||||||
|
extra: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.setCookie(
|
||||||
|
fastify.config.refreshCookieName,
|
||||||
|
refreshToken,
|
||||||
|
refreshCookieOptions(fastify.config),
|
||||||
|
);
|
||||||
|
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
|
||||||
|
|
||||||
|
const accessToken = fastify.issueAccessToken(employee);
|
||||||
|
return sendData(reply, request, {
|
||||||
|
accessToken,
|
||||||
|
expiresIn: accessTokenExpiresIn,
|
||||||
|
mustChangePassword: Boolean(employee.must_change_password),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
fastify.post("/refresh", async (request, reply) => {
|
fastify.post("/refresh", async (request, reply) => {
|
||||||
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
|
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
|
||||||
if (typeof refreshToken !== "string" || !refreshToken) {
|
if (typeof refreshToken !== "string" || !refreshToken) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const crypto = require("node:crypto");
|
||||||
const Fastify = require("fastify");
|
const Fastify = require("fastify");
|
||||||
const fastifyCookie = require("@fastify/cookie");
|
const fastifyCookie = require("@fastify/cookie");
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ const { signPayload } = require("../../../src/utils/signedUrl");
|
|||||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
const JWT_SECRET = "test-auth-secret";
|
const JWT_SECRET = "test-auth-secret";
|
||||||
|
|
||||||
|
function hashEmailLoginCode(code) {
|
||||||
|
return crypto.createHmac("sha256", JWT_SECRET).update(`${EMPLOYEE_ID}:${code}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
async function buildApp(passwordHash) {
|
async function buildApp(passwordHash) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
await app.register(fastifyCookie);
|
await app.register(fastifyCookie);
|
||||||
@@ -28,6 +33,10 @@ async function buildApp(passwordHash) {
|
|||||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||||
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
||||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||||
|
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||||
|
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||||
|
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||||
|
|
||||||
@@ -40,6 +49,10 @@ async function buildApp(passwordHash) {
|
|||||||
updateMany: authSessionUpdateMany,
|
updateMany: authSessionUpdateMany,
|
||||||
create: authSessionCreate,
|
create: authSessionCreate,
|
||||||
},
|
},
|
||||||
|
emailLoginCode: {
|
||||||
|
update: emailLoginCodeUpdate,
|
||||||
|
updateMany: emailLoginCodeUpdateMany,
|
||||||
|
},
|
||||||
auditLog: {
|
auditLog: {
|
||||||
create: auditCreate,
|
create: auditCreate,
|
||||||
},
|
},
|
||||||
@@ -57,6 +70,8 @@ async function buildApp(passwordHash) {
|
|||||||
loginFailWindowSeconds: 300,
|
loginFailWindowSeconds: 300,
|
||||||
loginLockSeconds: 900,
|
loginLockSeconds: 900,
|
||||||
loginFailMaxAttempts: 5,
|
loginFailMaxAttempts: 5,
|
||||||
|
emailLoginCodeTtlSeconds: 600,
|
||||||
|
emailLoginCodeMaxAttempts: 5,
|
||||||
rateLimitEnabled: false,
|
rateLimitEnabled: false,
|
||||||
jwtSecret: JWT_SECRET,
|
jwtSecret: JWT_SECRET,
|
||||||
appOrigin: "http://127.0.0.1:3000",
|
appOrigin: "http://127.0.0.1:3000",
|
||||||
@@ -66,6 +81,13 @@ async function buildApp(passwordHash) {
|
|||||||
employee: {
|
employee: {
|
||||||
findFirst: employeeFindFirst,
|
findFirst: employeeFindFirst,
|
||||||
},
|
},
|
||||||
|
emailLoginCode: {
|
||||||
|
create: emailLoginCodeCreate,
|
||||||
|
findFirst: emailLoginCodeFindFirst,
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: auditCreate,
|
||||||
|
},
|
||||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
});
|
});
|
||||||
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||||
@@ -93,6 +115,10 @@ async function buildApp(passwordHash) {
|
|||||||
authSessionUpdateMany,
|
authSessionUpdateMany,
|
||||||
authSessionCreate,
|
authSessionCreate,
|
||||||
auditCreate,
|
auditCreate,
|
||||||
|
emailLoginCodeCreate,
|
||||||
|
emailLoginCodeFindFirst,
|
||||||
|
emailLoginCodeUpdate,
|
||||||
|
emailLoginCodeUpdateMany,
|
||||||
wecomFindFirst,
|
wecomFindFirst,
|
||||||
wecomUpsert,
|
wecomUpsert,
|
||||||
};
|
};
|
||||||
@@ -245,4 +271,141 @@ describe("auth login route", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests an email login code without exposing account existence", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456);
|
||||||
|
const { app, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/email-login/code",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
payload: { email: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({});
|
||||||
|
expect(emailLoginCodeCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
employee_id: EMPLOYEE_ID,
|
||||||
|
code_hash: hashEmailLoginCode("123456"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(auditCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
|
||||||
|
actor_id: EMPLOYEE_ID,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
randomSpy.mockRestore();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reveal missing accounts when requesting an email login code", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||||
|
employeeFindFirst.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/email-login/code",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
payload: { email: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({});
|
||||||
|
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in with a valid email code and creates a refresh session", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, authSessionCreate, auditCreate } = await buildApp(passwordHash);
|
||||||
|
emailLoginCodeFindFirst.mockResolvedValueOnce({
|
||||||
|
id: "email-code-1",
|
||||||
|
code_hash: hashEmailLoginCode("654321"),
|
||||||
|
attempts_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/email-login/verify",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-device-fingerprint": "email-code-device",
|
||||||
|
},
|
||||||
|
payload: { email: "[email protected]", code: "654321" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
accessToken: "access-token",
|
||||||
|
mustChangePassword: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: "email-code-1", used_at: null },
|
||||||
|
data: { used_at: expect.any(Date) },
|
||||||
|
});
|
||||||
|
expect(authSessionCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
employee_id: EMPLOYEE_ID,
|
||||||
|
device_fingerprint: "email-code-device",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(auditCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: "AUTH_EMAIL_LOGIN_SUCCESS",
|
||||||
|
actor_id: EMPLOYEE_ID,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(response.headers["set-cookie"]).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining("refresh_token="),
|
||||||
|
expect.stringContaining("has_session=1"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid email login code and records the failed attempt", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdate, auditCreate } = await buildApp(passwordHash);
|
||||||
|
emailLoginCodeFindFirst.mockResolvedValueOnce({
|
||||||
|
id: "email-code-1",
|
||||||
|
code_hash: hashEmailLoginCode("654321"),
|
||||||
|
attempts_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/email-login/verify",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-device-fingerprint": "email-code-device",
|
||||||
|
},
|
||||||
|
payload: { email: "[email protected]", code: "000000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||||
|
expect(emailLoginCodeUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: "email-code-1" },
|
||||||
|
data: { attempts_count: { increment: 1 } },
|
||||||
|
});
|
||||||
|
expect(auditCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: "AUTH_EMAIL_LOGIN_FAILED",
|
||||||
|
actor_id: EMPLOYEE_ID,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户