feat(client): 首次登录改密守卫与错误文案(F35,F38)
- 登录响应支持 mustChangePassword 并强制跳转改密页 - 新增改密页面与全局路由守卫 - 完善 AUTH_002/SYS_429/VALID_00x 等错误提示
这个提交包含在:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 || "请求失败,请稍后重试。";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
bootstrapByRefresh: () => Promise<void>;
|
||||
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) => {
|
||||
const commitAuthenticatedUser = useCallback(
|
||||
(accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
|
||||
tokenRef.current = accessToken;
|
||||
setState(buildStateWithUser(accessToken, me));
|
||||
}, []);
|
||||
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,8 +39,12 @@ export function LoginPage() {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
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)}`);
|
||||
|
||||
@@ -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 <FullPageLoading />;
|
||||
@@ -33,17 +33,24 @@ export function RequireAuth({ children }: PropsWithChildren) {
|
||||
);
|
||||
}
|
||||
|
||||
if (mustChangePassword && location.pathname !== "/change-password") {
|
||||
return <Navigate to="/change-password" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function GuestOnly({ children }: PropsWithChildren) {
|
||||
const { accessToken, me, ready } = useAuth();
|
||||
const { accessToken, me, ready, mustChangePassword } = useAuth();
|
||||
|
||||
if (!ready) {
|
||||
return <FullPageLoading />;
|
||||
}
|
||||
|
||||
if (accessToken && me) {
|
||||
if (mustChangePassword) {
|
||||
return <Navigate to="/change-password" replace />;
|
||||
}
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route index element={<LoginPage />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/change-password"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AuthLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<ChangePasswordPage />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户