oa-email-code-login 实现邮箱验证码登录

这个提交包含在:
2026-05-27 06:49:58 +08:00
父节点 afa03c0b71
当前提交 7f96cc0379
共修改 21 个文件,包含 1027 行新增和 45 行删除
+22
查看文件
@@ -35,6 +35,28 @@ export async function resetPassword(payload: { email: string; code: string; newP
});
}
export async function requestEmailLoginCode(payload: { email: string }) {
return apiRequestData<Record<string, never>>("/api/auth/email-login/code", {
method: "POST",
auth: false,
headers: JSON_HEADERS,
body: JSON.stringify(payload),
});
}
export async function verifyEmailLoginCode(payload: { email: string; code: string }) {
return apiRequestData<LoginResponse>("/api/auth/email-login/verify", {
method: "POST",
auth: false,
retryOn401: false,
headers: {
...JSON_HEADERS,
"x-device-fingerprint": getDeviceFingerprint(),
},
body: JSON.stringify(payload),
});
}
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
export type WecomEntryConfig = {
+4
查看文件
@@ -129,6 +129,10 @@ export function mapApiErrorMessage(
return "当前设备校验信息缺失,请刷新页面后重试。";
}
if (details?.code === "invalid_or_expired") {
return "验证码无效或已过期,请重新获取。";
}
if (details?.payment === "already_paid_conflict") {
return "该报销单已支付且付款信息冲突,请刷新后确认。";
}
+16 -1
查看文件
@@ -21,6 +21,7 @@ import { changePassword as changePasswordApi } from "../api/auth";
import { clearPwaRuntimeCaches } from "../pwa";
import {
fetchCurrentEmployee,
loginWithEmailCode as loginWithEmailCodeRequest,
loginWithPassword,
logoutSession,
refreshSession,
@@ -37,6 +38,7 @@ type OfflineAuthSnapshot = {
type AuthContextValue = AuthState & {
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
loginWithEmailCode: (email: string, code: string) => Promise<{ mustChangePassword: boolean }>;
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
@@ -426,6 +428,18 @@ export function AuthProvider({ children }: PropsWithChildren) {
[commitAuthenticatedUser, scheduleProactiveRefresh],
);
const loginWithEmailCode = useCallback(
async (email: string, code: string) => {
const result = await loginWithEmailCodeRequest({ email, code });
const mustChangePassword = Boolean(result.mustChangePassword);
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me, mustChangePassword);
scheduleProactiveRefresh(result.expiresIn);
return { mustChangePassword };
},
[commitAuthenticatedUser, scheduleProactiveRefresh],
);
const changePassword = useCallback(
async (oldPassword: string, newPassword: string) => {
const result = await changePasswordApi({ oldPassword, newPassword });
@@ -525,6 +539,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
() => ({
...state,
login,
loginWithEmailCode,
changePassword,
logout,
bootstrapByRefresh,
@@ -532,7 +547,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
clearSession,
offlineAccessMode,
}),
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state],
[bootstrapByRefresh, changePassword, clearSession, login, loginWithEmailCode, logout, offlineAccessMode, refreshMe, state],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+6
查看文件
@@ -1,4 +1,5 @@
import { apiRequestData } from "../api/http";
import { verifyEmailLoginCode } from "../api/auth";
import type { LoginRequest, LoginResponse, MeResponse } from "../api/types";
import { getDeviceFingerprint } from "./deviceFingerprint";
@@ -32,6 +33,11 @@ export async function loginWithPassword(payload: LoginRequest) {
});
}
export async function loginWithEmailCode(payload: { email: string; code: string }) {
resetRefreshSessionCache();
return verifyEmailLoginCode(payload);
}
async function requestRefreshSession() {
return apiRequestData<LoginResponse>("/api/auth/refresh", {
method: "POST",
+5 -1
查看文件
@@ -14,9 +14,13 @@ type AuthPanelProps = {
export function AuthPanel({ title, subtitle, children, footer, className, "data-testid": testId }: AuthPanelProps) {
return (
<section
className={cn("w-full rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8", className)}
className={cn(
"relative w-full overflow-hidden rounded-2xl border border-slate-200/80 bg-white/95 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.10)] ring-1 ring-white/70 backdrop-blur sm:p-8",
className,
)}
data-testid={testId}
>
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
<div className="space-y-7">
<AppBrand compact />
<div>
+11 -2
查看文件
@@ -6,8 +6,17 @@ export function AuthLayout() {
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
return (
<div className="min-h-screen bg-[var(--color-bg-canvas)] px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
<div className="relative min-h-screen overflow-hidden bg-[#f4f7fa] px-4 py-8 sm:px-6 lg:px-10">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px), linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
<div className="relative mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
{updateReady ? (
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
<div className="font-medium text-slate-900">系统有新版本</div>
+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) ? (