diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index dd526638..dfbcd064 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -25,3 +25,83 @@ or hidden according to the active Issue; do not silently re-enable them. and post-trial capabilities instead of page-local warning blocks. - Trial-facing PC lists must hide obvious TEST/SMOKE records by default when a page-level toggle exists; the toggle may reveal them for troubleshooting. + +## Scenario: Auth Pages And PWA Updates + +### 1. Scope / Trigger + +- Trigger: login, password change, password recovery or other unauthenticated + forms are active while the PWA service worker finds a new frontend version. +- Applies to `client/src/pwa.ts`, `client/src/layouts/AuthLayout.tsx`, and + auth routes such as `/login`, `/change-password`, `/forgot-password`, and + future reset-password pages. + +### 2. Signatures + +- Event name: `hyb-oa:pwa-update-ready`. +- Event detail: `{ applyUpdate?: () => void | Promise }`. +- Route predicate: auth input pages must be checked by pathname before calling + `updateServiceWorker(true)`. + +### 3. Contracts + +- Auth input pages must not auto-reload when `onNeedRefresh()` fires. +- Auth input pages must show an explicit update prompt with a user-triggered + refresh action. +- Non-auth pages may keep automatic service-worker update behavior when the + task does not change broader runtime policy. +- Passwords and verification codes must stay only in React component state; do + not write them to `localStorage`, `sessionStorage`, URL params, reports, or + Trellis task evidence. +- Password visibility controls must use icon buttons with accessible labels and + preserve the current input value. + +### 4. Validation & Error Matrix + +- PWA update on `/login` -> dispatch update-ready event and keep form state. +- PWA update on `/forgot-password` -> dispatch update-ready event and keep + email/code/password fields in memory. +- User clicks refresh in the prompt -> run the captured update handler. +- User dismisses prompt -> no reload and no form reset. +- Password reset request returns success for any email -> show generic sent + message; do not reveal whether the account exists. + +### 5. Good/Base/Bad Cases + +- Good: user is typing a password, update prompt appears, typed values remain. +- Base: user is on a normal portal page, PWA update can refresh per existing + runtime behavior. +- Bad: service-worker update auto-refreshes an auth form and clears credentials. +- Bad: tests use real-looking personal email addresses or persist example + passwords outside the test file. + +### 6. Tests Required + +- Frontend typecheck and build. +- Focused Playwright auth UX test asserting: + - login password can be shown/hidden without losing value; + - forgot-password flow calls request-code and reset endpoints; + - auth-page PWA update prompt appears without clearing inputs. +- UI design rules check when auth page layout changes. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +onNeedRefresh() { + void updateServiceWorker?.(true); +} +``` + +#### Correct + +```ts +onNeedRefresh() { + if (isAuthInputPath(window.location.pathname)) { + window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } })); + return; + } + applyUpdate(); +} +``` diff --git a/.trellis/tasks/05-26-oa-login-password-recovery-ux/check.jsonl b/.trellis/tasks/05-26-oa-login-password-recovery-ux/check.jsonl new file mode 100644 index 00000000..c5f0e687 --- /dev/null +++ b/.trellis/tasks/05-26-oa-login-password-recovery-ux/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify frontend login UX, build and visual rules"} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Verify no passwords/codes/secrets are persisted or exposed"} diff --git a/.trellis/tasks/05-26-oa-login-password-recovery-ux/implement.jsonl b/.trellis/tasks/05-26-oa-login-password-recovery-ux/implement.jsonl new file mode 100644 index 00000000..d5a8880d --- /dev/null +++ b/.trellis/tasks/05-26-oa-login-password-recovery-ux/implement.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Login UI, route, form and visual interaction rules"} +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Existing auth endpoint boundary and backend validation expectations"} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Password, code and secret handling boundary"} diff --git a/.trellis/tasks/05-26-oa-login-password-recovery-ux/implement.md b/.trellis/tasks/05-26-oa-login-password-recovery-ux/implement.md new file mode 100644 index 00000000..35795e2a --- /dev/null +++ b/.trellis/tasks/05-26-oa-login-password-recovery-ux/implement.md @@ -0,0 +1,34 @@ +# 实施记录 + +## 改动范围 + +- `client/src/api/auth.ts`:新增 `forgotPassword` 和 `resetPassword` API 封装。 +- `client/src/components/ui/password-input.tsx`:新增统一密码输入控件,支持显示/隐藏密码。 +- `client/src/pages/LoginPage.tsx`:增加“忘记密码?”入口,并使用 `PasswordInput`。 +- `client/src/pages/ForgotPasswordPage.tsx`:新增邮箱验证码找回密码页面。 +- `client/src/pages/ChangePasswordPage.tsx`:旧密码、新密码和确认新密码复用 `PasswordInput`。 +- `client/src/router/index.tsx`:新增 `/forgot-password` 路由。 +- `client/src/pwa.ts`、`client/src/layouts/AuthLayout.tsx`、`client/src/hooks/usePwaUpdatePrompt.ts`:认证页 PWA 更新不再自动刷新,改为提示用户手动刷新。 +- `client/tests/e2e/login-portal-ux.spec.ts`:增加登录密码显隐、找回密码入口、PWA 更新不清空输入和找回密码流程的 focused E2E。 +- `client/README.md`:同步找回密码口径为邮箱验证码 + 管理员重置兜底。 + +## 安全边界 + +- 前端不持久化密码、验证码或 token。 +- 找回密码不暴露邮箱是否存在,沿用后端枚举保护。 +- 不读取、不输出、不修改真实 `live-smoke.env` 密码。 +- 不执行真实账号密码重置。 + +## 验证记录 + +- `cd client && npm run typecheck`:PASS。 +- `E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts -g "登录页提供找回密码|找回密码流程" --workers=1 --reporter=list`:2 passed,已在测试数据中使用通用 `.test` 邮箱。 +- `cd client && npm run build`:PASS。 +- `node scripts/qa/check-ui-design-rules.mjs --strict`:PASS,0 findings。 +- `python3 ./.trellis/scripts/task.py validate 05-26-oa-login-password-recovery-ux`:PASS。 +- `node scripts/qa/check-trellis-governance.mjs`:PASS。 +- `git diff --check`:PASS。 + +## 已知非本任务问题 + +完整 `login-portal-ux.spec.ts` 中 3 条旧 Portal 断言失败,失败点是测试仍寻找历史文案“快捷操作栏/角色重点 / 常用入口”,而当前页面快照显示为“常用/团队状态/待处理”。该问题与本任务登录、找回密码、PWA 更新改动无关,未纳入本任务修复范围。 diff --git a/.trellis/tasks/05-26-oa-login-password-recovery-ux/prd.md b/.trellis/tasks/05-26-oa-login-password-recovery-ux/prd.md new file mode 100644 index 00000000..0a0f1d58 --- /dev/null +++ b/.trellis/tasks/05-26-oa-login-password-recovery-ux/prd.md @@ -0,0 +1,121 @@ +# 登录找回密码与输入体验改进 + +## Goal + +修复真实试运行 Day 1 暴露的登录基础体验问题:登录页缺少自助忘记密码入口、密码输入不可查看、页面打开后可能因 PWA 自动更新二次刷新导致用户正在输入的账号和密码丢失。目标是把登录体验补到成熟 OA 系统应有水平,同时保持生产安全边界。 + +## Scope + +- 新增前端自助找回密码入口和页面。 +- 复用后端已有 `POST /api/auth/forgot-password` 与 `POST /api/auth/reset-password` 接口。 +- 登录页、改密页、找回密码页的密码框增加“显示/隐藏”切换。 +- 调整 PWA 更新策略,避免认证页自动刷新打断输入。 +- 补充前端 E2E 覆盖登录输入保留、密码可见切换和找回密码流程。 + +## What I Already Know + +- 后端已经存在自助接口: + - `POST /api/auth/forgot-password` + - `POST /api/auth/reset-password` +- 前端当前没有 `/forgot-password` 或 `/reset-password` 路由。 +- `client/src/pages/LoginPage.tsx` 当前只有账号密码登录、企业微信入口和 OIDC 入口。 +- `client/src/pages/ChangePasswordPage.tsx` 当前密码框均不可见。 +- `client/src/main.tsx` 的 chunk recovery 会在 chunk 加载失败时自动刷新。 +- `client/src/pwa.ts` 的 `onNeedRefresh()` 当前会自动调用 `updateServiceWorker?.(true)`,可能在用户输入登录信息时刷新页面。 +- `client/README.md` 当前写着试运行/生产建议采用管理员重置密码,但后端自助接口已存在,可在前端补入口并保留管理员重置作为兜底。 + +## Requirements + +- 登录页增加“忘记密码?”入口,位置放在密码字段附近,不打扰主登录动作。 +- 找回密码页面采用两步式单页流程: + - 第一步输入邮箱,请求验证码。 + - 第二步输入验证码、新密码、确认新密码并提交。 +- 不提示“该邮箱是否存在”,后端保持枚举保护;前端统一提示“如账号可用,将发送验证码”。 +- 密码框可切换显示/隐藏: + - 登录密码。 + - 改密页旧密码、新密码、确认新密码。 + - 找回密码页新密码、确认新密码。 +- 密码可见按钮使用图标按钮,带可访问标签,不改变输入内容。 +- PWA 更新不得在认证页自动刷新: + - `/login` + - `/change-password` + - `/forgot-password` + - `/reset-password` 如后续存在 +- 认证页遇到 PWA 更新时只提示“系统有新版本,完成当前输入后刷新”,由用户主动刷新或离开认证流程后再处理。 +- 不保存用户密码到 localStorage/sessionStorage;不通过持久化密码来解决刷新丢失。 +- 保留管理员重置密码作为生产兜底,不在聊天、日志、报告或仓库输出真实密码。 + +## Acceptance Criteria + +- [x] 登录页可进入找回密码。 +- [x] 找回密码流程可请求验证码并提交新密码。 +- [x] 三类密码输入框均可显示/隐藏,切换后已输入内容不丢失。 +- [x] 认证页 PWA 更新不会自动刷新页面并清空输入。 +- [x] 登录页输入账号和密码时,异步加载企业微信/SSO 配置不会导致输入丢失。 +- [x] 前端 typecheck 和 build 通过。 +- [x] 相关 Playwright E2E 通过。 +- [x] 不输出或持久化密码、验证码、token、Secret 或完整账号。 + +## Definition of Done + +- 用户可在公网登录页看到成熟、可理解的忘记密码入口。 +- 登录、改密、找回密码的密码输入控件体验一致。 +- 认证页不再被 PWA 自动更新打断输入。 +- 测试覆盖关键交互和回归风险。 +- 代码提交前通过 Trellis validate、governance、`git diff --check`。 + +## Out of Scope + +- 不改后端密码策略。 +- 不启用或配置 SMTP。 +- 不读取或输出真实 `live-smoke.env` 密码。 +- 不执行真实账号密码重置。 +- 不把管理员重置密码兜底删除。 + +## Proposed Implementation + +1. API 层: + - 在 `client/src/api/auth.ts` 增加 `forgotPassword(email)` 与 `resetPassword(payload)`。 +2. UI 组件: + - 新增或复用一个轻量 `PasswordInput` 组件,内部使用 `Input` + lucide `Eye/EyeOff` 图标按钮。 +3. 页面和路由: + - 新增 `ForgotPasswordPage`。 + - 在 `client/src/router/index.tsx` 增加 `/forgot-password` guest route。 + - 在 `LoginPage` 密码字段旁增加“忘记密码?”链接。 + - 在 `ChangePasswordPage` 使用统一密码输入组件。 +4. PWA 更新: + - 调整 `client/src/pwa.ts`:认证页不自动 `updateServiceWorker(true)`;改为派发事件。 + - 在登录/改密/找回页面显示轻提示或 toast,避免刷新导致输入丢失。 +5. 测试: + - 新增/扩展登录体验 E2E:忘记密码入口、验证码流程 mock、密码显隐、PWA 更新不清空输入。 + - 运行 `cd client && npm run typecheck && npm run build`。 + +## Test Plan + +```bash +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 +node scripts/qa/check-ui-design-rules.mjs --strict +python3 ./.trellis/scripts/task.py validate 05-26-oa-login-password-recovery-ux +node scripts/qa/check-trellis-governance.mjs +git diff --check +``` + +说明:同文件既有 Portal 旧断言目前会找历史文案“快捷操作栏/角色重点”,与本任务登录体验改动无关;本任务只把新增登录体验用例作为 focused Playwright 门禁。 + +## Open Questions + +- 建议默认方案:找回密码仅支持邮箱,不支持手机号短信。原因是后端现有接口已经是邮箱验证码,手机号短信需要新通道和额外安全设计。 + +## Technical Notes + +- Frontend spec: `.trellis/spec/hyb-oa/frontend-react-vite-design.md` +- Backend spec: `.trellis/spec/hyb-oa/backend-fastify-prisma.md` +- Security spec: `.trellis/spec/hyb-oa/security-privacy.md` +- Design rules: `docs/产品设计规则.md` +- Login page: `client/src/pages/LoginPage.tsx` +- Change password page: `client/src/pages/ChangePasswordPage.tsx` +- Auth API: `client/src/api/auth.ts` +- Router: `client/src/router/index.tsx` +- PWA update behavior: `client/src/pwa.ts` diff --git a/.trellis/tasks/05-26-oa-login-password-recovery-ux/task.json b/.trellis/tasks/05-26-oa-login-password-recovery-ux/task.json new file mode 100644 index 00000000..50ad8688 --- /dev/null +++ b/.trellis/tasks/05-26-oa-login-password-recovery-ux/task.json @@ -0,0 +1,33 @@ +{ + "id": "oa-login-password-recovery-ux", + "name": "oa-login-password-recovery-ux", + "title": "登录找回密码与输入体验改进", + "description": "改进 OA 登录体验,新增邮箱验证码找回密码、密码显隐控件,并避免认证页 PWA 更新自动刷新打断输入。", + "status": "in_progress", + "dev_type": null, + "scope": "client/auth-ux", + "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": [ + "client/src/pages/LoginPage.tsx", + "client/src/pages/ForgotPasswordPage.tsx", + "client/src/pages/ChangePasswordPage.tsx", + "client/src/api/auth.ts", + "client/src/pwa.ts", + "client/tests/e2e/login-portal-ux.spec.ts" + ], + "notes": "", + "meta": {} +} diff --git a/client/README.md b/client/README.md index c6578185..24f20da7 100644 --- a/client/README.md +++ b/client/README.md @@ -51,7 +51,7 @@ npm run dev | admin | `admin@hyinda.com` | `AdminPassword1234` | | ceo | `ceo@hyinda.com` | `CeoPassword1234` | -忘记密码:试运行/生产建议采用“管理员重置密码”策略。 +忘记密码:登录页提供邮箱验证码找回;管理员重置密码仍作为试运行/生产兜底。 ## 5. 菜单与权限(当前壳层) - 仪表盘:登录即可访问。 diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 5a4aa3dc..9f0d085b 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -17,6 +17,24 @@ export async function changePassword(payload: { oldPassword: string; newPassword }); } +export async function forgotPassword(payload: { email: string }) { + return apiRequestData>("/api/auth/forgot-password", { + method: "POST", + auth: false, + headers: JSON_HEADERS, + body: JSON.stringify(payload), + }); +} + +export async function resetPassword(payload: { email: string; code: string; newPassword: string }) { + return apiRequestData>("/api/auth/reset-password", { + method: "POST", + auth: false, + headers: JSON_HEADERS, + body: JSON.stringify(payload), + }); +} + export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string }; export type WecomEntryConfig = { diff --git a/client/src/components/ui/password-input.tsx b/client/src/components/ui/password-input.tsx new file mode 100644 index 00000000..6249ad85 --- /dev/null +++ b/client/src/components/ui/password-input.tsx @@ -0,0 +1,41 @@ +import { Eye, EyeOff } from "lucide-react"; +import * as React from "react"; +import { cn } from "../../lib/utils"; +import { Button } from "./button"; +import { Input, type InputProps } from "./input"; + +export type PasswordInputProps = Omit & { + revealLabel?: string; + hideLabel?: string; +}; + +export const PasswordInput = React.forwardRef( + ({ className, revealLabel = "显示密码", hideLabel = "隐藏密码", ...props }, ref) => { + const [visible, setVisible] = React.useState(false); + const Icon = visible ? EyeOff : Eye; + const label = visible ? hideLabel : revealLabel; + + return ( +
+ + +
+ ); + }, +); + +PasswordInput.displayName = "PasswordInput"; diff --git a/client/src/hooks/usePwaUpdatePrompt.ts b/client/src/hooks/usePwaUpdatePrompt.ts new file mode 100644 index 00000000..ad47784d --- /dev/null +++ b/client/src/hooks/usePwaUpdatePrompt.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { PWA_UPDATE_READY_EVENT } from "../lib/pwaUpdateEvents"; + +type PwaUpdateReadyDetail = { + applyUpdate?: () => void | Promise; +}; + +function resolveApplyUpdate(event: Event) { + const detail = (event as CustomEvent).detail; + return typeof detail?.applyUpdate === "function" ? detail.applyUpdate : null; +} + +export function usePwaUpdatePrompt() { + const applyUpdateRef = useRef<(() => void | Promise) | null>(null); + const [updateReady, setUpdateReady] = useState(false); + + useEffect(() => { + const handleUpdateReady = (event: Event) => { + applyUpdateRef.current = resolveApplyUpdate(event); + setUpdateReady(true); + }; + + window.addEventListener(PWA_UPDATE_READY_EVENT, handleUpdateReady); + return () => { + window.removeEventListener(PWA_UPDATE_READY_EVENT, handleUpdateReady); + }; + }, []); + + const applyUpdate = useCallback(() => { + const handler = applyUpdateRef.current; + if (handler) { + void handler(); + return; + } + window.location.reload(); + }, []); + + const dismissUpdate = useCallback(() => { + setUpdateReady(false); + }, []); + + return { + updateReady, + applyUpdate, + dismissUpdate, + }; +} diff --git a/client/src/layouts/AuthLayout.tsx b/client/src/layouts/AuthLayout.tsx index 0fdac154..a669b571 100644 --- a/client/src/layouts/AuthLayout.tsx +++ b/client/src/layouts/AuthLayout.tsx @@ -1,13 +1,31 @@ import { Outlet } from "react-router-dom"; import { AppBrand } from "../components/shell/AppBrand"; +import { Button } from "../components/ui/button"; +import { usePwaUpdatePrompt } from "../hooks/usePwaUpdatePrompt"; export function AuthLayout() { + const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt(); + return (
+ {updateReady ? ( +
+
系统有新版本
+
完成当前输入后刷新页面即可更新,当前表单不会被自动打断。
+
+ + +
+
+ ) : null}
diff --git a/client/src/lib/pwaUpdateEvents.ts b/client/src/lib/pwaUpdateEvents.ts new file mode 100644 index 00000000..bddf924e --- /dev/null +++ b/client/src/lib/pwaUpdateEvents.ts @@ -0,0 +1 @@ +export const PWA_UPDATE_READY_EVENT = "hyb-oa:pwa-update-ready"; diff --git a/client/src/pages/ChangePasswordPage.tsx b/client/src/pages/ChangePasswordPage.tsx index 450ac711..3adf965d 100644 --- a/client/src/pages/ChangePasswordPage.tsx +++ b/client/src/pages/ChangePasswordPage.tsx @@ -4,8 +4,8 @@ import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { useAuth } from "../auth/AuthContext"; import { Button } from "../components/ui/button"; -import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; +import { PasswordInput } from "../components/ui/password-input"; import { markOnboardingEntryReady } from "../lib/onboardingGuide"; const PASSWORD_REQUIREMENTS = [ @@ -62,26 +62,24 @@ export function ChangePasswordPage() {
- setOldPassword(event.target.value)} placeholder="请输入旧密码" - type="password" value={oldPassword} />
- setNewPassword(event.target.value)} placeholder="至少 12 位,建议包含大小写/数字" - type="password" value={newPassword} />

密码要求:{PASSWORD_REQUIREMENTS.join(";")}。

@@ -98,12 +96,11 @@ export function ChangePasswordPage() {
- setConfirmPassword(event.target.value)} placeholder="再次输入新密码" - type="password" value={confirmPassword} />
diff --git a/client/src/pages/ForgotPasswordPage.tsx b/client/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 00000000..3056e980 --- /dev/null +++ b/client/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { forgotPassword, resetPassword } from "../api/auth"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { PasswordInput } from "../components/ui/password-input"; + +const PASSWORD_REQUIREMENTS = [ + "至少 12 位", + "包含大写字母、小写字母和数字", + "不要使用公司名、常见顺序或过于简单的组合", +]; + +export function ForgotPasswordPage() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [codeRequested, setCodeRequested] = useState(false); + const [submittingCode, setSubmittingCode] = useState(false); + const [submittingReset, setSubmittingReset] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + async function handleRequestCode(event: React.FormEvent) { + event.preventDefault(); + setError(""); + setMessage(""); + + const normalizedEmail = email.trim(); + if (!normalizedEmail) { + setError("请输入邮箱。"); + return; + } + + setSubmittingCode(true); + try { + await forgotPassword({ email: normalizedEmail }); + setCodeRequested(true); + setMessage("如账号可用,验证码将发送到该邮箱。请在有效期内完成重置。"); + } catch (err) { + if (isApiError(err)) { + setError(mapApiErrorMessage(err.code, err.details, err.message)); + } else { + setError("请求验证码失败,请稍后重试或联系管理员。"); + } + } finally { + setSubmittingCode(false); + } + } + + async function handleResetPassword(event: React.FormEvent) { + event.preventDefault(); + setError(""); + + const normalizedEmail = email.trim(); + const normalizedCode = code.trim(); + if (!normalizedEmail) { + setError("请输入邮箱。"); + return; + } + if (normalizedCode.length !== 6) { + setError("请输入 6 位验证码。"); + return; + } + if (newPassword !== confirmPassword) { + setError("两次输入的新密码不一致,请确认后再提交。"); + return; + } + + setSubmittingReset(true); + try { + await resetPassword({ + email: normalizedEmail, + code: normalizedCode, + newPassword, + }); + setMessage("密码已重置,请使用新密码登录。"); + navigate("/login", { replace: true }); + } catch (err) { + if (isApiError(err)) { + setError(mapApiErrorMessage(err.code, err.details, err.message)); + } else { + setError("重置密码失败,请稍后重试或联系管理员。"); + } + } finally { + setSubmittingReset(false); + } + } + + return ( +
+
+

找回密码

+

使用账号邮箱接收验证码,设置新密码后重新登录。

+
+ + +
+ + setEmail(event.target.value)} + placeholder="请输入账号邮箱" + type="email" + value={email} + /> +
+ + + + + {codeRequested ? ( +
+
+ + setCode(event.target.value.replace(/\D/g, "").slice(0, 6))} + placeholder="6 位验证码" + value={code} + /> +
+ +
+ + setNewPassword(event.target.value)} + placeholder="至少 12 位,建议包含大小写/数字" + value={newPassword} + /> +

密码要求:{PASSWORD_REQUIREMENTS.join(";")}。

+
+ +
+ + setConfirmPassword(event.target.value)} + placeholder="再次输入新密码" + value={confirmPassword} + /> +
+ + +
+ ) : null} + + {message ? ( +
+ {message} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + 返回登录 + +
+
+ ); +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index c6659c29..555adb4f 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { fetchSsoProviders, fetchWecomConfig } from "../api/auth"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; @@ -9,6 +9,7 @@ import { getDeviceFingerprint } from "../auth/deviceFingerprint"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; +import { PasswordInput } from "../components/ui/password-input"; import { isWecomWebView, resolveDefaultWorkspacePath } from "../lib/runtimeContext"; import { buildWecomLoginUrl, @@ -122,14 +123,18 @@ export function LoginPage() {
- - + + + 忘记密码? + +
+ setPassword(event.target.value)} placeholder="请输入密码" - type="password" value={password} />
diff --git a/client/src/pwa.ts b/client/src/pwa.ts index 46336dc7..7d0b99f2 100644 --- a/client/src/pwa.ts +++ b/client/src/pwa.ts @@ -1,9 +1,15 @@ import { registerSW } from "virtual:pwa-register"; import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./lib/runtimeContext"; +import { PWA_UPDATE_READY_EVENT } from "./lib/pwaUpdateEvents"; const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i; const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"]; const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"]; +const AUTH_INPUT_PATHS = ["/login", "/change-password", "/forgot-password", "/reset-password"]; + +function isAuthInputPath(pathname: string) { + return AUTH_INPUT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); +} function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) { if (!canUseWindow() || !registration) { @@ -42,11 +48,20 @@ export function registerPwaIfEnabled() { updateServiceWorker = registerSW({ immediate: true, onNeedRefresh() { - void clearPwaRuntimeCaches() - .catch(() => undefined) - .finally(() => { - void updateServiceWorker?.(true); - }); + const applyUpdate = () => { + void clearPwaRuntimeCaches() + .catch(() => undefined) + .finally(() => { + void updateServiceWorker?.(true); + }); + }; + + if (canUseWindow() && isAuthInputPath(window.location.pathname)) { + window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } })); + return; + } + + applyUpdate(); }, onOfflineReady() { if (canUseWindow()) { diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 6e891828..d9a56c6d 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -7,6 +7,7 @@ import { BudgetsPage } from "../pages/BudgetsPage"; import { ChangePasswordPage } from "../pages/ChangePasswordPage"; import { DashboardPage } from "../pages/DashboardPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; +import { ForgotPasswordPage } from "../pages/ForgotPasswordPage"; import { LoginPage } from "../pages/LoginPage"; import { NotFoundPage } from "../pages/NotFoundPage"; import { ProfilePage } from "../pages/ProfilePage"; @@ -91,6 +92,17 @@ export function AppRouter() { } /> + + + + } + > + } /> + + { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope([])), + }); + }), + page.route("**/api/auth/wecom/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + enabled: false, + entryEnabled: false, + oauthEnabled: false, + corpId: "", + agentId: "", + loginUrl: null, + bindEnabled: false, + defaultReturnTo: "/portal", + status: "disabled", + statusReasons: ["disabled"], + })), + }); + }), + ]); +} + function mockNotificationList(page: import("@playwright/test").Page) { return page.route((url) => { return url.pathname === "/api/notifications"; @@ -283,6 +313,93 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" }); } +test("登录页提供找回密码、密码显隐,PWA 更新不会清空正在输入的内容", async ({ page }) => { + await mockGuestAuthChrome(page); + + await page.goto("/login"); + + await page.getByTestId("login-identifier").fill("user@example.test"); + await page.getByTestId("login-password").fill("PasswordA1234"); + + await expect(page.getByTestId("login-password")).toHaveAttribute("type", "password"); + await page.getByRole("button", { name: "显示密码" }).click(); + await expect(page.getByTestId("login-password")).toHaveAttribute("type", "text"); + await expect(page.getByTestId("login-identifier")).toHaveValue("user@example.test"); + await expect(page.getByTestId("login-password")).toHaveValue("PasswordA1234"); + + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("hyb-oa:pwa-update-ready", { + detail: { + applyUpdate: () => { + window.sessionStorage.setItem("pwa-update-applied", "1"); + }, + }, + })); + }); + + await expect(page.getByTestId("auth-pwa-update-notice")).toBeVisible(); + await expect(page.getByTestId("login-identifier")).toHaveValue("user@example.test"); + await expect(page.getByTestId("login-password")).toHaveValue("PasswordA1234"); + + await page.getByRole("button", { name: "稍后" }).click(); + await expect(page.getByTestId("auth-pwa-update-notice")).toBeHidden(); + await expect(page.getByTestId("login-identifier")).toHaveValue("user@example.test"); + await expect(page.getByTestId("login-password")).toHaveValue("PasswordA1234"); + + await page.getByRole("link", { name: "忘记密码?" }).click(); + await expect(page).toHaveURL(/\/forgot-password$/); +}); + +test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => { + const requestedEmails: string[] = []; + const resetPayloads: unknown[] = []; + + await page.route("**/api/auth/forgot-password", async (route) => { + const payload = route.request().postDataJSON() as { email?: string }; + requestedEmails.push(String(payload.email ?? "")); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({})), + }); + }); + + await page.route("**/api/auth/reset-password", async (route) => { + resetPayloads.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({})), + }); + }); + + await page.goto("/forgot-password"); + await page.getByTestId("forgot-password-email").fill("user@example.test"); + await page.getByTestId("forgot-password-request-code").click(); + + await expect(page.getByTestId("forgot-password-message")).toContainText("如账号可用"); + expect(requestedEmails).toEqual(["user@example.test"]); + + await page.getByTestId("forgot-password-code").fill("123456"); + await page.getByTestId("forgot-password-new").fill("NewPassword1234"); + await page.getByTestId("forgot-password-confirm").fill("NewPassword1234"); + + await page.getByRole("button", { name: "显示密码" }).first().click(); + await expect(page.getByTestId("forgot-password-new")).toHaveAttribute("type", "text"); + await expect(page.getByTestId("forgot-password-new")).toHaveValue("NewPassword1234"); + + await page.getByTestId("forgot-password-submit").click(); + + await expect(page).toHaveURL(/\/login$/); + expect(resetPayloads).toEqual([ + { + email: "user@example.test", + code: "123456", + newPassword: "NewPassword1234", + }, + ]); +}); + test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { await mockPasswordLogin(page); await mockNavigation(page, "employee");