241 行
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|