oa-email-code-login 实现邮箱验证码登录
这个提交包含在:
@@ -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 = {
|
||||
|
||||
@@ -129,6 +129,10 @@ export function mapApiErrorMessage(
|
||||
return "当前设备校验信息缺失,请刷新页面后重试。";
|
||||
}
|
||||
|
||||
if (details?.code === "invalid_or_expired") {
|
||||
return "验证码无效或已过期,请重新获取。";
|
||||
}
|
||||
|
||||
if (details?.payment === "already_paid_conflict") {
|
||||
return "该报销单已支付且付款信息冲突,请刷新后确认。";
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
在新工单中引用
屏蔽一个用户