feat(client): 首次登录改密守卫与错误文案(F35,F38)

- 登录响应支持 mustChangePassword 并强制跳转改密页
- 新增改密页面与全局路由守卫
- 完善 AUTH_002/SYS_429/VALID_00x 等错误提示
这个提交包含在:
2026-02-09 18:16:22 +08:00
父节点 7d50a85412
当前提交 2566289d37
共修改 8 个文件,包含 265 行新增和 13 行删除
+19
查看文件
@@ -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<LoginResponse>("/api/auth/change-password", {
method: "POST",
headers: {
...JSON_HEADERS,
"x-device-fingerprint": getDeviceFingerprint(),
},
body: JSON.stringify(payload),
});
}
+51
查看文件
@@ -7,6 +7,17 @@ export function mapApiErrorMessage(
return "账号或密码错误,请检查后重试。"; 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") { if (code === "AUTH_003") {
return "账号已离职/禁用,请联系管理员。"; return "账号已离职/禁用,请联系管理员。";
} }
@@ -15,6 +26,10 @@ export function mapApiErrorMessage(
return "登录状态失效,请重新登录。"; return "登录状态失效,请重新登录。";
} }
if (code === "AUTH_005") {
return "登录状态已失效,请重新登录。";
}
if (code === "PERM_001") { if (code === "PERM_001") {
return "当前账号无权限执行该操作。"; return "当前账号无权限执行该操作。";
} }
@@ -31,10 +46,34 @@ export function mapApiErrorMessage(
return "服务暂不可用,请稍后重试或联系管理员。"; return "服务暂不可用,请稍后重试或联系管理员。";
} }
if (code === "SYS_429") {
return "请求过于频繁,请稍后重试。";
}
if (code === "VALID_002") { if (code === "VALID_002") {
return "输入内容长度不足,请补充后再提交。"; 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 (code === "VALID_001") {
if (details?.payment === "already_paid_conflict") { if (details?.payment === "already_paid_conflict") {
return "该报销单已支付且付款信息冲突,请刷新后确认。"; return "该报销单已支付且付款信息冲突,请刷新后确认。";
@@ -63,6 +102,18 @@ export function mapApiErrorMessage(
if (details?.task === "already_processed") { if (details?.task === "already_processed") {
return "该任务已被处理,请刷新列表。"; 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 || "请求失败,请稍后重试。"; return fallbackMessage || "请求失败,请稍后重试。";
+2
查看文件
@@ -19,6 +19,7 @@ export type LoginRequest = {
export type LoginResponse = { export type LoginResponse = {
accessToken: string; accessToken: string;
mustChangePassword?: boolean;
}; };
export type MeResponse = { export type MeResponse = {
@@ -37,6 +38,7 @@ export type AuthState = {
accessToken: string | null; accessToken: string | null;
me: MeResponse | null; me: MeResponse | null;
permissions: string[]; permissions: string[];
mustChangePassword: boolean;
ready: boolean; ready: boolean;
}; };
+51 -9
查看文件
@@ -10,6 +10,7 @@ import {
} from "react"; } from "react";
import { configureHttpAuth } from "../api/http"; import { configureHttpAuth } from "../api/http";
import type { AuthState, MeResponse } from "../api/types"; import type { AuthState, MeResponse } from "../api/types";
import { changePassword as changePasswordApi } from "../api/auth";
import { import {
fetchCurrentEmployee, fetchCurrentEmployee,
loginWithPassword, loginWithPassword,
@@ -19,7 +20,8 @@ import {
import { resolvePermissionsForRoles } from "./permissions"; import { resolvePermissionsForRoles } from "./permissions";
type AuthContextValue = AuthState & { type AuthContextValue = AuthState & {
login: (identifier: string, password: string) => Promise<void>; login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>; bootstrapByRefresh: () => Promise<void>;
clearSession: () => void; clearSession: () => void;
@@ -31,14 +33,35 @@ const EMPTY_STATE: AuthState = {
accessToken: null, accessToken: null,
me: null, me: null,
permissions: [], permissions: [],
mustChangePassword: false,
ready: 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 { return {
accessToken, accessToken,
me, me,
permissions: resolvePermissionsForRoles(me.roleCodes ?? []), permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
mustChangePassword,
ready: true, ready: true,
}; };
} }
@@ -53,25 +76,32 @@ export function AuthProvider({ children }: PropsWithChildren) {
const clearSession = useCallback(() => { const clearSession = useCallback(() => {
tokenRef.current = null; tokenRef.current = null;
writeMustChangePasswordToStorage(false);
setState((previous) => ({ setState((previous) => ({
...previous, ...previous,
accessToken: null, accessToken: null,
me: null, me: null,
permissions: [], permissions: [],
mustChangePassword: false,
ready: true, ready: true,
})); }));
}, []); }, []);
const commitAuthenticatedUser = useCallback((accessToken: string, me: MeResponse) => { const commitAuthenticatedUser = useCallback(
tokenRef.current = accessToken; (accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
setState(buildStateWithUser(accessToken, me)); tokenRef.current = accessToken;
}, []); writeMustChangePasswordToStorage(mustChangePassword);
setState(buildStateWithUser(accessToken, me, mustChangePassword));
},
[],
);
const bootstrapByRefresh = useCallback(async () => { const bootstrapByRefresh = useCallback(async () => {
try { try {
const refreshed = await refreshSession(); const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken); const me = await fetchCurrentEmployee(refreshed.accessToken);
commitAuthenticatedUser(refreshed.accessToken, me); const mustChangePassword = readMustChangePasswordFromStorage();
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
} catch { } catch {
clearSession(); clearSession();
} }
@@ -80,8 +110,19 @@ export function AuthProvider({ children }: PropsWithChildren) {
const login = useCallback( const login = useCallback(
async (identifier: string, password: string) => { async (identifier: string, password: string) => {
const result = await loginWithPassword({ identifier, password }); const result = await loginWithPassword({ identifier, password });
const mustChangePassword = Boolean(result.mustChangePassword);
const me = await fetchCurrentEmployee(result.accessToken); 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], [commitAuthenticatedUser],
); );
@@ -124,11 +165,12 @@ export function AuthProvider({ children }: PropsWithChildren) {
() => ({ () => ({
...state, ...state,
login, login,
changePassword,
logout, logout,
bootstrapByRefresh, bootstrapByRefresh,
clearSession, clearSession,
}), }),
[bootstrapByRefresh, clearSession, login, logout, state], [bootstrapByRefresh, changePassword, clearSession, login, logout, state],
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+115
查看文件
@@ -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<HTMLFormElement>) {
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 (
<Card className="space-y-6 border-primary-100 p-6">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="text-2xl font-bold text-slate-900">修改密码</h1>
<p className="text-sm text-secondary">
为了账号安全,请先修改初始密码后再进入系统。
</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<Label htmlFor="oldPassword">旧密码</Label>
<Input
autoComplete="current-password"
data-testid="change-password-old"
id="oldPassword"
onChange={(event) => setOldPassword(event.target.value)}
placeholder="请输入旧密码"
type="password"
value={oldPassword}
/>
</div>
<div className="space-y-1">
<Label htmlFor="newPassword">新密码</Label>
<Input
autoComplete="new-password"
data-testid="change-password-new"
id="newPassword"
onChange={(event) => setNewPassword(event.target.value)}
placeholder="至少 12 位,建议包含大小写/数字"
type="password"
value={newPassword}
/>
</div>
<div className="space-y-1">
<Label htmlFor="confirmPassword">确认新密码</Label>
<Input
autoComplete="new-password"
id="confirmPassword"
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入新密码"
type="password"
value={confirmPassword}
/>
</div>
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="change-password-error"
>
{error}
</p>
) : null}
<Button
data-testid="change-password-submit"
className="w-full"
disabled={submitting || !oldPassword.trim() || !newPassword.trim() || !confirmPassword.trim()}
type="submit"
>
{submitting ? "提交中..." : "确认修改"}
</Button>
</form>
</Card>
);
}
+6 -2
查看文件
@@ -39,8 +39,12 @@ export function LoginPage() {
setError(""); setError("");
try { try {
await login(identifier.trim(), password); const result = await login(identifier.trim(), password);
navigate(redirectTo, { replace: true }); if (result.mustChangePassword) {
navigate("/change-password", { replace: true });
} else {
navigate(redirectTo, { replace: true });
}
} catch (err) { } catch (err) {
if (isApiError(err)) { if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
+9 -2
查看文件
@@ -18,7 +18,7 @@ function toLoginRedirect(pathname: string, search: string, hash: string) {
export function RequireAuth({ children }: PropsWithChildren) { export function RequireAuth({ children }: PropsWithChildren) {
const location = useLocation(); const location = useLocation();
const { accessToken, me, ready } = useAuth(); const { accessToken, me, ready, mustChangePassword } = useAuth();
if (!ready) { if (!ready) {
return <FullPageLoading />; return <FullPageLoading />;
@@ -33,17 +33,24 @@ export function RequireAuth({ children }: PropsWithChildren) {
); );
} }
if (mustChangePassword && location.pathname !== "/change-password") {
return <Navigate to="/change-password" replace />;
}
return <>{children}</>; return <>{children}</>;
} }
export function GuestOnly({ children }: PropsWithChildren) { export function GuestOnly({ children }: PropsWithChildren) {
const { accessToken, me, ready } = useAuth(); const { accessToken, me, ready, mustChangePassword } = useAuth();
if (!ready) { if (!ready) {
return <FullPageLoading />; return <FullPageLoading />;
} }
if (accessToken && me) { if (accessToken && me) {
if (mustChangePassword) {
return <Navigate to="/change-password" replace />;
}
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
+12
查看文件
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppShellLayout } from "../layouts/AppShellLayout"; import { AppShellLayout } from "../layouts/AppShellLayout";
import { AuthLayout } from "../layouts/AuthLayout"; import { AuthLayout } from "../layouts/AuthLayout";
import { AuditLogsPage } from "../pages/AuditLogsPage"; import { AuditLogsPage } from "../pages/AuditLogsPage";
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
import { DashboardPage } from "../pages/DashboardPage"; import { DashboardPage } from "../pages/DashboardPage";
import { DelegationsPage } from "../pages/DelegationsPage"; import { DelegationsPage } from "../pages/DelegationsPage";
import { ForbiddenPage } from "../pages/ForbiddenPage"; import { ForbiddenPage } from "../pages/ForbiddenPage";
@@ -31,6 +32,17 @@ export function AppRouter() {
<Route index element={<LoginPage />} /> <Route index element={<LoginPage />} />
</Route> </Route>
<Route
path="/change-password"
element={
<RequireAuth>
<AuthLayout />
</RequireAuth>
}
>
<Route index element={<ChangePasswordPage />} />
</Route>
<Route <Route
path="/" path="/"
element={ element={