文件
hyb-oa/client/src/pages/ForgotPasswordPage.tsx
T

241 行
8.8 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { MailCheck } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { fetchAuthCapabilities, forgotPassword, resetPassword } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { AuthPanel } from "../components/auth/AuthPanel";
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("");
const authCapabilitiesQuery = useQuery({
queryKey: ["auth", "capabilities"],
queryFn: () => fetchAuthCapabilities(),
staleTime: 60_000,
});
const emailDeliveryReady = authCapabilitiesQuery.data?.emailDelivery?.ready === true;
const emailDeliveryChecking = authCapabilitiesQuery.isLoading;
const emailDeliveryUnavailable = authCapabilitiesQuery.isSuccess && !emailDeliveryReady;
const emailDeliveryUnknown = authCapabilitiesQuery.isError;
async function handleRequestCode(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setMessage("");
const normalizedEmail = email.trim();
if (!normalizedEmail) {
setError("请输入邮箱。");
return;
}
if (!emailDeliveryReady) {
setError(
emailDeliveryChecking
? "正在检查邮件服务,请稍后再试。"
: "邮件验证码暂不可用,请联系管理员处理。",
);
return;
}
setSubmittingCode(true);
try {
await forgotPassword({ email: normalizedEmail });
setCodeRequested(true);
setMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
} else {
setError("请求验证码失败,请稍后重试或联系管理员。");
}
} finally {
setSubmittingCode(false);
}
}
async function handleResetPassword(event: React.FormEvent<HTMLFormElement>) {
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 (
<AuthPanel
title="找回密码"
subtitle="用企业邮箱验证码重置"
data-testid="forgot-password-page"
footer={(
<Link className="font-medium text-primary hover:underline" to="/login">
返回登录
</Link>
)}
>
<div className="ux-step-list mb-5">
<div className="ux-step-item">
<span className="ux-step-item__index">1</span>
<span>输入账号绑定的企业邮箱,系统发送 6 位验证码。</span>
</div>
<div className="ux-step-item">
<span className="ux-step-item__index">2</span>
<span>填写验证码和新密码;验证码 10 分钟内有效。</span>
</div>
</div>
<form className="space-y-4" onSubmit={handleRequestCode}>
<div className="space-y-2">
<Label htmlFor="forgot-password-email">邮箱</Label>
<Input
autoComplete="email"
data-testid="forgot-password-email"
id="forgot-password-email"
onChange={(event) => setEmail(event.target.value)}
placeholder="请输入账号邮箱"
type="email"
value={email}
/>
</div>
{!emailDeliveryReady ? (
<div
aria-live="polite"
className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-sm leading-6 text-amber-800"
data-testid="forgot-password-email-delivery-status"
>
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
<span>
{emailDeliveryChecking
? "正在检查企业邮箱验证码服务。"
: emailDeliveryUnknown
? "暂时无法确认邮件服务,请稍后刷新或联系管理员。"
: emailDeliveryUnavailable
? "邮件验证码暂不可用,请联系管理员处理。"
: "邮件验证码暂不可用。"}
</span>
</div>
) : null}
<Button className="w-full" data-testid="forgot-password-request-code" disabled={submittingCode || !email.trim() || !emailDeliveryReady} type="submit">
{emailDeliveryChecking ? "检查邮件服务..." : submittingCode ? "发送中..." : codeRequested ? "重新发送验证码" : "发送验证码"}
</Button>
</form>
{codeRequested ? (
<form className="mt-5 space-y-4 border-t border-slate-100 pt-5" onSubmit={handleResetPassword}>
<div className="space-y-2">
<Label htmlFor="forgot-password-code">验证码</Label>
<Input
autoComplete="one-time-code"
data-testid="forgot-password-code"
id="forgot-password-code"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="6 位验证码"
value={code}
/>
</div>
<div className="space-y-2">
<Label htmlFor="forgot-password-new">新密码</Label>
<PasswordInput
autoComplete="new-password"
data-testid="forgot-password-new"
id="forgot-password-new"
onChange={(event) => setNewPassword(event.target.value)}
placeholder="至少 12 位,建议包含大小写/数字"
value={newPassword}
/>
<p className="text-xs text-slate-500">密码要求:{PASSWORD_REQUIREMENTS.join(";")}。</p>
</div>
<div className="space-y-2">
<Label htmlFor="forgot-password-confirm">确认新密码</Label>
<PasswordInput
autoComplete="new-password"
data-testid="forgot-password-confirm"
id="forgot-password-confirm"
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入新密码"
value={confirmPassword}
/>
</div>
<Button
className="w-full"
data-testid="forgot-password-submit"
disabled={submittingReset || !email.trim() || code.length !== 6 || !newPassword.trim() || !confirmPassword.trim()}
type="submit"
>
{submittingReset ? "提交中..." : "重置密码"}
</Button>
</form>
) : null}
{message ? (
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm text-emerald-700" data-testid="forgot-password-message">
{message}
</div>
) : null}
{error ? (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600" data-testid="forgot-password-error">
{error}
</div>
) : null}
</AuthPanel>
);
}