diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts new file mode 100644 index 00000000..9291d227 --- /dev/null +++ b/client/src/api/auth.ts @@ -0,0 +1,19 @@ +import { apiRequestData } from "./http"; +import { getDeviceFingerprint } from "../auth/deviceFingerprint"; +import type { LoginResponse } from "./types"; + +const JSON_HEADERS = { + "content-type": "application/json", +}; + +export async function changePassword(payload: { oldPassword: string; newPassword: string }) { + return apiRequestData("/api/auth/change-password", { + method: "POST", + headers: { + ...JSON_HEADERS, + "x-device-fingerprint": getDeviceFingerprint(), + }, + body: JSON.stringify(payload), + }); +} + diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index c678279d..ee8c904d 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -7,6 +7,17 @@ export function mapApiErrorMessage( return "账号或密码错误,请检查后重试。"; } + if (code === "AUTH_002") { + const lockedUntil = typeof details?.lockedUntil === "string" ? details.lockedUntil : null; + if (lockedUntil) { + const until = new Date(lockedUntil); + if (!Number.isNaN(until.getTime())) { + return `账号已锁定至 ${until.toLocaleString()},请稍后重试。`; + } + } + return "账号已锁定,请稍后重试。"; + } + if (code === "AUTH_003") { return "账号已离职/禁用,请联系管理员。"; } @@ -15,6 +26,10 @@ export function mapApiErrorMessage( return "登录状态失效,请重新登录。"; } + if (code === "AUTH_005") { + return "登录状态已失效,请重新登录。"; + } + if (code === "PERM_001") { return "当前账号无权限执行该操作。"; } @@ -31,10 +46,34 @@ export function mapApiErrorMessage( return "服务暂不可用,请稍后重试或联系管理员。"; } + if (code === "SYS_429") { + return "请求过于频繁,请稍后重试。"; + } + if (code === "VALID_002") { return "输入内容长度不足,请补充后再提交。"; } + if (code === "VALID_003") { + return "票据疑似重复,请核对后再上传或联系财务处理。"; + } + + if (code === "VALID_004") { + return "原始单据已审批通过/付款,当前操作不允许。"; + } + + if (code === "VALID_005") { + return "票据已绑定报销单,无需重复绑定。"; + } + + if (code === "VALID_006") { + return "OCR 重试次数已达上限,请稍后人工处理。"; + } + + if (code === "VALID_007") { + return "OCR 重试过于频繁,请稍后再试。"; + } + if (code === "VALID_001") { if (details?.payment === "already_paid_conflict") { return "该报销单已支付且付款信息冲突,请刷新后确认。"; @@ -63,6 +102,18 @@ export function mapApiErrorMessage( if (details?.task === "already_processed") { return "该任务已被处理,请刷新列表。"; } + + if (details?.invoice === "not_bound") { + return "该票据未绑定报销单,无法解绑。"; + } + + if (details?.reimbursement === "invalid_status_for_unbind") { + return "仅草稿/驳回状态的报销单允许解绑票据。"; + } + + if (details?.ocr === "disabled") { + return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。"; + } } return fallbackMessage || "请求失败,请稍后重试。"; diff --git a/client/src/api/types.ts b/client/src/api/types.ts index cb5a710a..b03af272 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -19,6 +19,7 @@ export type LoginRequest = { export type LoginResponse = { accessToken: string; + mustChangePassword?: boolean; }; export type MeResponse = { @@ -37,6 +38,7 @@ export type AuthState = { accessToken: string | null; me: MeResponse | null; permissions: string[]; + mustChangePassword: boolean; ready: boolean; }; diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index e4a81892..326753a2 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -10,6 +10,7 @@ import { } from "react"; import { configureHttpAuth } from "../api/http"; import type { AuthState, MeResponse } from "../api/types"; +import { changePassword as changePasswordApi } from "../api/auth"; import { fetchCurrentEmployee, loginWithPassword, @@ -19,7 +20,8 @@ import { import { resolvePermissionsForRoles } from "./permissions"; type AuthContextValue = AuthState & { - login: (identifier: string, password: string) => Promise; + login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>; + changePassword: (oldPassword: string, newPassword: string) => Promise; logout: () => Promise; bootstrapByRefresh: () => Promise; clearSession: () => void; @@ -31,14 +33,35 @@ const EMPTY_STATE: AuthState = { accessToken: null, me: null, permissions: [], + mustChangePassword: false, ready: false, }; -function buildStateWithUser(accessToken: string, me: MeResponse): AuthState { +const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password"; + +function readMustChangePasswordFromStorage() { + return typeof window !== "undefined" && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1"; +} + +function writeMustChangePasswordToStorage(value: boolean) { + if (typeof window === "undefined") return; + if (value) { + window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1"); + } else { + window.localStorage.removeItem(MUST_CHANGE_PASSWORD_STORAGE_KEY); + } +} + +function buildStateWithUser( + accessToken: string, + me: MeResponse, + mustChangePassword: boolean, +): AuthState { return { accessToken, me, permissions: resolvePermissionsForRoles(me.roleCodes ?? []), + mustChangePassword, ready: true, }; } @@ -53,25 +76,32 @@ export function AuthProvider({ children }: PropsWithChildren) { const clearSession = useCallback(() => { tokenRef.current = null; + writeMustChangePasswordToStorage(false); setState((previous) => ({ ...previous, accessToken: null, me: null, permissions: [], + mustChangePassword: false, ready: true, })); }, []); - const commitAuthenticatedUser = useCallback((accessToken: string, me: MeResponse) => { - tokenRef.current = accessToken; - setState(buildStateWithUser(accessToken, me)); - }, []); + const commitAuthenticatedUser = useCallback( + (accessToken: string, me: MeResponse, mustChangePassword: boolean) => { + tokenRef.current = accessToken; + writeMustChangePasswordToStorage(mustChangePassword); + setState(buildStateWithUser(accessToken, me, mustChangePassword)); + }, + [], + ); const bootstrapByRefresh = useCallback(async () => { try { const refreshed = await refreshSession(); const me = await fetchCurrentEmployee(refreshed.accessToken); - commitAuthenticatedUser(refreshed.accessToken, me); + const mustChangePassword = readMustChangePasswordFromStorage(); + commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); } catch { clearSession(); } @@ -80,8 +110,19 @@ export function AuthProvider({ children }: PropsWithChildren) { const login = useCallback( async (identifier: string, password: string) => { const result = await loginWithPassword({ identifier, password }); + const mustChangePassword = Boolean(result.mustChangePassword); const me = await fetchCurrentEmployee(result.accessToken); - commitAuthenticatedUser(result.accessToken, me); + commitAuthenticatedUser(result.accessToken, me, mustChangePassword); + return { mustChangePassword }; + }, + [commitAuthenticatedUser], + ); + + const changePassword = useCallback( + async (oldPassword: string, newPassword: string) => { + const result = await changePasswordApi({ oldPassword, newPassword }); + const me = await fetchCurrentEmployee(result.accessToken); + commitAuthenticatedUser(result.accessToken, me, false); }, [commitAuthenticatedUser], ); @@ -124,11 +165,12 @@ export function AuthProvider({ children }: PropsWithChildren) { () => ({ ...state, login, + changePassword, logout, bootstrapByRefresh, clearSession, }), - [bootstrapByRefresh, clearSession, login, logout, state], + [bootstrapByRefresh, changePassword, clearSession, login, logout, state], ); return {children}; diff --git a/client/src/pages/ChangePasswordPage.tsx b/client/src/pages/ChangePasswordPage.tsx new file mode 100644 index 00000000..2a91850f --- /dev/null +++ b/client/src/pages/ChangePasswordPage.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { useAuth } from "../auth/AuthContext"; +import { Button } from "../components/ui/button"; +import { Card } from "../components/ui/card"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; + +export function ChangePasswordPage() { + const navigate = useNavigate(); + const { changePassword } = useAuth(); + + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("两次输入的新密码不一致,请确认后再提交。"); + return; + } + + setSubmitting(true); + try { + await changePassword(oldPassword, newPassword); + navigate("/dashboard", { replace: true }); + } catch (err) { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + } else { + setError("修改密码失败,请稍后重试"); + } + } finally { + setSubmitting(false); + } + } + + return ( + +
+

hyb-oa

+

修改密码

+

+ 为了账号安全,请先修改初始密码后再进入系统。 +

+
+ +
+
+ + setOldPassword(event.target.value)} + placeholder="请输入旧密码" + type="password" + value={oldPassword} + /> +
+ +
+ + setNewPassword(event.target.value)} + placeholder="至少 12 位,建议包含大小写/数字" + type="password" + value={newPassword} + /> +
+ +
+ + setConfirmPassword(event.target.value)} + placeholder="再次输入新密码" + type="password" + value={confirmPassword} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + + +
+
+ ); +} + diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 4e072b2c..88b6610a 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -39,8 +39,12 @@ export function LoginPage() { setError(""); try { - await login(identifier.trim(), password); - navigate(redirectTo, { replace: true }); + const result = await login(identifier.trim(), password); + if (result.mustChangePassword) { + navigate("/change-password", { replace: true }); + } else { + navigate(redirectTo, { replace: true }); + } } catch (err) { if (isApiError(err)) { setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); diff --git a/client/src/router/guards.tsx b/client/src/router/guards.tsx index b79df63f..5023e794 100644 --- a/client/src/router/guards.tsx +++ b/client/src/router/guards.tsx @@ -18,7 +18,7 @@ function toLoginRedirect(pathname: string, search: string, hash: string) { export function RequireAuth({ children }: PropsWithChildren) { const location = useLocation(); - const { accessToken, me, ready } = useAuth(); + const { accessToken, me, ready, mustChangePassword } = useAuth(); if (!ready) { return ; @@ -33,17 +33,24 @@ export function RequireAuth({ children }: PropsWithChildren) { ); } + if (mustChangePassword && location.pathname !== "/change-password") { + return ; + } + return <>{children}; } export function GuestOnly({ children }: PropsWithChildren) { - const { accessToken, me, ready } = useAuth(); + const { accessToken, me, ready, mustChangePassword } = useAuth(); if (!ready) { return ; } if (accessToken && me) { + if (mustChangePassword) { + return ; + } return ; } diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 50bcc42c..0d0b10cc 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppShellLayout } from "../layouts/AppShellLayout"; import { AuthLayout } from "../layouts/AuthLayout"; import { AuditLogsPage } from "../pages/AuditLogsPage"; +import { ChangePasswordPage } from "../pages/ChangePasswordPage"; import { DashboardPage } from "../pages/DashboardPage"; import { DelegationsPage } from "../pages/DelegationsPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; @@ -31,6 +32,17 @@ export function AppRouter() { } /> + + + + } + > + } /> + +