oa-email-code-login 实现邮箱验证码登录
这个提交包含在:
+194
-5
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
||||
import { fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type WecomNotice,
|
||||
} from "../lib/wecomEntry";
|
||||
|
||||
type LoginMode = "password" | "email_code";
|
||||
|
||||
function resolveRedirect(searchParams: URLSearchParams, fallback: string) {
|
||||
const redirect = searchParams.get("redirect");
|
||||
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
|
||||
@@ -40,12 +42,21 @@ function noticeClasses(tone: WecomNotice["tone"]) {
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const { login, loginWithEmailCode } = useAuth();
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>("password");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [emailLoginEmail, setEmailLoginEmail] = useState("");
|
||||
const [emailLoginCode, setEmailLoginCode] = useState("");
|
||||
const [emailCodeRequested, setEmailCodeRequested] = useState(false);
|
||||
const [emailCodeCooldown, setEmailCodeCooldown] = useState(0);
|
||||
const [emailCodeSubmitting, setEmailCodeSubmitting] = useState(false);
|
||||
const [emailCodeSending, setEmailCodeSending] = useState(false);
|
||||
const [emailCodeMessage, setEmailCodeMessage] = useState("");
|
||||
const [emailCodeError, setEmailCodeError] = useState("");
|
||||
const [passwordFallbackOpen, setPasswordFallbackOpen] = useState(false);
|
||||
|
||||
const isWecomContext = isWecomWebView();
|
||||
@@ -75,6 +86,18 @@ export function LoginPage() {
|
||||
const showWecomPrimary = isWecomContext;
|
||||
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailCodeCooldown <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setEmailCodeCooldown((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [emailCodeCooldown]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
@@ -107,6 +130,79 @@ export function LoginPage() {
|
||||
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
|
||||
}
|
||||
|
||||
async function handleRequestEmailCode(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setEmailCodeError("");
|
||||
setEmailCodeMessage("");
|
||||
|
||||
const email = emailLoginEmail.trim();
|
||||
if (!email) {
|
||||
setEmailCodeError("请输入企业邮箱。");
|
||||
return;
|
||||
}
|
||||
|
||||
setEmailCodeSending(true);
|
||||
try {
|
||||
await requestEmailLoginCode({ email });
|
||||
setEmailCodeRequested(true);
|
||||
setEmailCodeCooldown(60);
|
||||
setEmailCodeMessage("如账号可用,验证码将发送到该邮箱。");
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||
} else if (err instanceof TypeError) {
|
||||
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
|
||||
} else {
|
||||
setEmailCodeError("发送验证码失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
setEmailCodeSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailCodeLogin(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setEmailCodeError("");
|
||||
|
||||
const email = emailLoginEmail.trim();
|
||||
const code = emailLoginCode.trim();
|
||||
if (!email) {
|
||||
setEmailCodeError("请输入企业邮箱。");
|
||||
return;
|
||||
}
|
||||
if (code.length !== 6) {
|
||||
setEmailCodeError("请输入 6 位验证码。");
|
||||
return;
|
||||
}
|
||||
|
||||
setEmailCodeSubmitting(true);
|
||||
try {
|
||||
const result = await loginWithEmailCode(email, code);
|
||||
if (result.mustChangePassword) {
|
||||
navigate("/change-password", { replace: true });
|
||||
} else {
|
||||
navigate(redirectTo, { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||
} else if (err instanceof TypeError) {
|
||||
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
|
||||
} else {
|
||||
setEmailCodeError("登录失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
setEmailCodeSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectLoginMode(mode: LoginMode) {
|
||||
setLoginMode(mode);
|
||||
setError("");
|
||||
setEmailCodeError("");
|
||||
setEmailCodeMessage("");
|
||||
}
|
||||
|
||||
function renderPasswordPanel(testId: string) {
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
@@ -150,8 +246,76 @@ export function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderEmailCodePanel(testId: string) {
|
||||
return (
|
||||
<div className="space-y-4" data-testid={testId}>
|
||||
<form className="space-y-4" onSubmit={handleRequestEmailCode}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-email-code-email">企业邮箱</Label>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
data-testid="login-email-code-email"
|
||||
id="login-email-code-email"
|
||||
onChange={(event) => setEmailLoginEmail(event.target.value)}
|
||||
placeholder="请输入企业邮箱"
|
||||
type="email"
|
||||
value={emailLoginEmail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="login-email-code-request"
|
||||
disabled={emailCodeSending || emailCodeCooldown > 0 || !emailLoginEmail.trim()}
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
>
|
||||
{emailCodeSending ? "发送中..." : emailCodeCooldown > 0 ? `${emailCodeCooldown} 秒后重发` : emailCodeRequested ? "重新发送验证码" : "发送验证码"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleEmailCodeLogin}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-email-code">验证码</Label>
|
||||
<Input
|
||||
autoComplete="one-time-code"
|
||||
data-testid="login-email-code"
|
||||
id="login-email-code"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(event) => setEmailLoginCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="6 位验证码"
|
||||
value={emailLoginCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{emailCodeMessage ? (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" data-testid="login-email-code-message">
|
||||
{emailCodeMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{emailCodeError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600" data-testid="login-email-code-error">
|
||||
{emailCodeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="login-email-code-submit"
|
||||
disabled={emailCodeSubmitting || !emailLoginEmail.trim() || emailLoginCode.length !== 6}
|
||||
type="submit"
|
||||
>
|
||||
{emailCodeSubmitting ? "登录中..." : "验证码登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : "账号密码登录"}>
|
||||
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : loginMode === "email_code" ? "邮箱验证码登录" : "账号密码登录"}>
|
||||
<div className="space-y-5">
|
||||
{wecomNotice ? (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
@@ -182,8 +346,33 @@ export function LoginPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showWecomPrimary ? (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl border border-slate-200 bg-slate-100/80 p-1" data-testid="login-mode-switch">
|
||||
<button
|
||||
aria-pressed={loginMode === "password"}
|
||||
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "password" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
|
||||
data-testid="login-mode-password"
|
||||
type="button"
|
||||
onClick={() => selectLoginMode("password")}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={loginMode === "email_code"}
|
||||
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "email_code" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
|
||||
data-testid="login-mode-email-code"
|
||||
type="button"
|
||||
onClick={() => selectLoginMode("email_code")}
|
||||
>
|
||||
邮箱验证码
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showPasswordPanel
|
||||
? renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||
? !showWecomPrimary && loginMode === "email_code"
|
||||
? renderEmailCodePanel("login-email-code-panel")
|
||||
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||
: null}
|
||||
|
||||
{!showWecomPrimary && (wecomEntry || oidcProvider) ? (
|
||||
|
||||
在新工单中引用
屏蔽一个用户