diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 53b0f27f..0ff88ebc 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -2,14 +2,18 @@ ## Source Of Truth -UI work must read `docs/产品设计规则.md` first and align with the Hyinda brand -style. For PC/mobile/WeCom pages, add Playwright or visual verification when -reasonable. +Frontend visual and interaction design uses the installed `frontend-design` +skill as the primary design authority. `docs/产品设计规则.md` remains a product, +brand and historical fact source, not the final aesthetic tie-breaker. For +PC/mobile/WeCom pages, add Playwright or visual verification when reasonable. ## Rules - Use existing React/Vite/Tailwind patterns and shared components before adding 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, table/list, status, details and predictable actions. - Mobile pages must use the unified mobile shell, card/list rows, safe-area and diff --git a/.trellis/tasks/05-26-oa-email-code-login/check.jsonl b/.trellis/tasks/05-26-oa-email-code-login/check.jsonl new file mode 100644 index 00000000..12959050 --- /dev/null +++ b/.trellis/tasks/05-26-oa-email-code-login/check.jsonl @@ -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."} diff --git a/.trellis/tasks/05-26-oa-email-code-login/implement.jsonl b/.trellis/tasks/05-26-oa-email-code-login/implement.jsonl new file mode 100644 index 00000000..4971a1b6 --- /dev/null +++ b/.trellis/tasks/05-26-oa-email-code-login/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/05-26-oa-email-code-login/implement.md b/.trellis/tasks/05-26-oa-email-code-login/implement.md new file mode 100644 index 00000000..952fa910 --- /dev/null +++ b/.trellis/tasks/05-26-oa-email-code-login/implement.md @@ -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 需要本地提交后另行确认。 diff --git a/.trellis/tasks/05-26-oa-email-code-login/prd.md b/.trellis/tasks/05-26-oa-email-code-login/prd.md new file mode 100644 index 00000000..594565f2 --- /dev/null +++ b/.trellis/tasks/05-26-oa-email-code-login/prd.md @@ -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` diff --git a/.trellis/tasks/05-26-oa-email-code-login/task.json b/.trellis/tasks/05-26-oa-email-code-login/task.json new file mode 100644 index 00000000..3c7e2b00 --- /dev/null +++ b/.trellis/tasks/05-26-oa-email-code-login/task.json @@ -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": {} +} diff --git a/AGENTS.md b/AGENTS.md index 0b5e8abc..f34a46d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,14 +19,14 @@ - 中文沟通;commit message 使用中文并引用 Trellis task 名称或任务 ID。 - 不泄露密码、token、Secret、真实票据、合同正文、银行资料、完整账号、完整本地路径或原始 payload。 - 不轻易新建文档;优先更新现有事实源、模块规约、操作手册、证据文档或当前 Trellis task。 -- 页面调整必须参考 `docs/产品设计规则.md`,并对齐公司主页 `www.hyinda.com` 的品牌风格。 +- 页面视觉与交互设计以 `frontend-design` skill 为准;`docs/产品设计规则.md` 仅保留业务事实、品牌识别和验收参考。 - 面向用户可测试的改动,只有部署与公网检查通过后才能告知用户可以测试。 ## 当前项目事实源 - 文档入口与状态:`docs/文档索引.md`、`docs/文档事实源状态矩阵.md` - Agent/Trellis 交付实践:`docs/Agent开发交付实践.md` -- 产品与视觉规则:`docs/产品设计规则.md` +- 产品/品牌事实与历史视觉规则:`docs/产品设计规则.md`(视觉裁决以 `frontend-design` skill 为准) - 报销、发票、OCR、附件规则:`docs/报销发票附件规则.md` - 试运行与自动化验证:`docs/试运行与自动化验证手册.md` - Trellis workflow:`.trellis/workflow.md` @@ -52,4 +52,4 @@ - 默认按 Trellis workflow 执行:task/prd -> design/implement notes(按需) -> implement -> check -> spec/docs update -> commit -> finish。 - 涉及权限模型、状态机、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 只用于追溯。 diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 9f0d085b..2213f303 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -35,6 +35,28 @@ export async function resetPassword(payload: { email: string; code: string; newP }); } +export async function requestEmailLoginCode(payload: { email: string }) { + return apiRequestData>("/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("/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 WecomEntryConfig = { diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 7b509363..76392802 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -129,6 +129,10 @@ export function mapApiErrorMessage( return "当前设备校验信息缺失,请刷新页面后重试。"; } + if (details?.code === "invalid_or_expired") { + return "验证码无效或已过期,请重新获取。"; + } + if (details?.payment === "already_paid_conflict") { return "该报销单已支付且付款信息冲突,请刷新后确认。"; } diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index 0e3db349..06ecc818 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -21,6 +21,7 @@ import { changePassword as changePasswordApi } from "../api/auth"; import { clearPwaRuntimeCaches } from "../pwa"; import { fetchCurrentEmployee, + loginWithEmailCode as loginWithEmailCodeRequest, loginWithPassword, logoutSession, refreshSession, @@ -37,6 +38,7 @@ type OfflineAuthSnapshot = { type AuthContextValue = AuthState & { login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>; + loginWithEmailCode: (email: string, code: string) => Promise<{ mustChangePassword: boolean }>; changePassword: (oldPassword: string, newPassword: string) => Promise; logout: () => Promise; bootstrapByRefresh: () => Promise; @@ -426,6 +428,18 @@ export function AuthProvider({ children }: PropsWithChildren) { [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( async (oldPassword: string, newPassword: string) => { const result = await changePasswordApi({ oldPassword, newPassword }); @@ -525,6 +539,7 @@ export function AuthProvider({ children }: PropsWithChildren) { () => ({ ...state, login, + loginWithEmailCode, changePassword, logout, bootstrapByRefresh, @@ -532,7 +547,7 @@ export function AuthProvider({ children }: PropsWithChildren) { clearSession, offlineAccessMode, }), - [bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state], + [bootstrapByRefresh, changePassword, clearSession, login, loginWithEmailCode, logout, offlineAccessMode, refreshMe, state], ); return {children}; diff --git a/client/src/auth/authService.ts b/client/src/auth/authService.ts index cba0771e..7e8b8a63 100644 --- a/client/src/auth/authService.ts +++ b/client/src/auth/authService.ts @@ -1,4 +1,5 @@ import { apiRequestData } from "../api/http"; +import { verifyEmailLoginCode } from "../api/auth"; import type { LoginRequest, LoginResponse, MeResponse } from "../api/types"; 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() { return apiRequestData("/api/auth/refresh", { method: "POST", diff --git a/client/src/components/auth/AuthPanel.tsx b/client/src/components/auth/AuthPanel.tsx index daef680e..935a5b77 100644 --- a/client/src/components/auth/AuthPanel.tsx +++ b/client/src/components/auth/AuthPanel.tsx @@ -14,9 +14,13 @@ type AuthPanelProps = { export function AuthPanel({ title, subtitle, children, footer, className, "data-testid": testId }: AuthPanelProps) { return (
+