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

547 行
21 KiB
TypeScript

import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Building2, MailCheck } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { fetchAuthCapabilities, fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
import { getDeviceFingerprint } from "../auth/deviceFingerprint";
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";
import { isWecomWebView, resolveDefaultWorkspacePath } from "../lib/runtimeContext";
import {
buildWecomLoginUrl,
resolveWecomBindNotice,
resolveWecomLoginNotice,
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("//")) {
return fallback;
}
return redirect;
}
function noticeClasses(tone: WecomNotice["tone"]) {
if (tone === "success") {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (tone === "warning") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
return "border-slate-200 bg-slate-50 text-slate-700";
}
export function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
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();
const defaultWorkspacePath = resolveDefaultWorkspacePath();
const redirectTo = resolveRedirect(searchParams, defaultWorkspacePath);
const ssoProvidersQuery = useQuery({
queryKey: ["auth", "sso-providers"],
queryFn: () => fetchSsoProviders(),
staleTime: 60_000,
});
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
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;
const wecomConfigQuery = useQuery({
queryKey: ["auth", "wecom-config"],
queryFn: () => fetchWecomConfig(),
staleTime: 60_000,
});
const wecomConfig = wecomConfigQuery.data;
const wecomEntry = wecomConfig?.entryEnabled && wecomConfig.loginUrl ? wecomConfig : null;
const pcWecomEntry = !isWecomContext && wecomConfig?.pcWebLoginEnabled && wecomConfig.pcLoginUrl
? wecomConfig
: null;
const wecomNotice = useMemo(
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
[searchParams],
);
const showWecomPrimary = isWecomContext;
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry) && !pcWecomEntry;
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);
setError("");
try {
const result = await login(identifier.trim(), password);
if (result.mustChangePassword) {
navigate("/change-password", { replace: true });
} else {
navigate(redirectTo, { replace: true });
}
} catch (err) {
if (isApiError(err)) {
setError(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
setError("登录失败,请稍后重试。");
}
} finally {
setSubmitting(false);
}
}
function handleWecomLogin() {
if (!wecomEntry?.loginUrl) {
return;
}
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
}
function handlePcWecomLogin() {
if (!pcWecomEntry?.pcLoginUrl) {
return;
}
window.location.assign(buildWecomLoginUrl(pcWecomEntry.pcLoginUrl, redirectTo, getDeviceFingerprint()));
}
async function handleRequestEmailCode(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setEmailCodeError("");
setEmailCodeMessage("");
const email = emailLoginEmail.trim();
if (!email) {
setEmailCodeError("请输入企业邮箱。");
return;
}
if (!emailDeliveryReady) {
setEmailCodeError(
emailDeliveryChecking
? "正在检查邮件服务,请稍后再试。"
: "邮件验证码暂不可用,请使用密码登录或联系管理员。",
);
return;
}
setEmailCodeSending(true);
try {
await requestEmailLoginCode({ email });
setEmailCodeRequested(true);
setEmailCodeCooldown(60);
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setEmailCodeError(resolveUserErrorMessage(err));
} 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(resolveUserErrorMessage(err));
} else if (err instanceof TypeError) {
setEmailCodeError("无法连接后端服务,请稍后再试或联系管理员。");
} else {
setEmailCodeError("登录失败,请稍后重试。");
}
} finally {
setEmailCodeSubmitting(false);
}
}
function selectLoginMode(mode: LoginMode) {
setLoginMode(mode);
setError("");
setEmailCodeError("");
setEmailCodeMessage("");
}
function renderBrowserEnterprisePanel() {
if (!pcWecomEntry && !showBrowserWecomNotice && !oidcProvider) {
return null;
}
return (
<section
className={pcWecomEntry
? "order-2 space-y-3 lg:order-1 lg:flex lg:min-h-[320px] lg:flex-col lg:justify-center lg:border-r lg:border-slate-200 lg:pr-8"
: "space-y-3"}
data-testid="login-enterprise-panel"
>
<div className={pcWecomEntry ? "lg:hidden" : ""}>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className="h-px flex-1 bg-slate-100" />
<span>企业入口</span>
<span className="h-px flex-1 bg-slate-100" />
</div>
</div>
{pcWecomEntry ? (
<div className="hidden lg:block">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Building2 aria-hidden="true" className="h-5 w-5" />
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-950">企业微信登录</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
前往企业微信官方登录页,使用手机企业微信扫码后进入 OA 工作台。
</p>
</div>
) : null}
{pcWecomEntry ? (
<Button
className="w-full lg:h-12 lg:border-transparent lg:bg-[var(--color-accent-primary)] lg:text-white lg:hover:border-transparent lg:hover:bg-[var(--color-accent-primary-hover)]"
data-testid="wecom-entry-pc"
type="button"
variant="secondary"
onClick={handlePcWecomLogin}
>
<Building2 aria-hidden="true" className="mr-2 hidden h-4 w-4 lg:block" />
企业微信扫码登录
</Button>
) : showBrowserWecomNotice ? (
<div
className="auth-side-entry flex gap-3 text-slate-600"
data-testid="wecom-entry-browser-notice"
>
<Building2 aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none text-primary" />
<div>
<div className="font-medium text-slate-900">企业微信一键进入</div>
<div>请从企业微信工作台打开 OA;普通浏览器使用密码或邮箱验证码。</div>
</div>
</div>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</section>
);
}
function renderPasswordPanel(testId: string) {
return (
<div data-testid={testId}>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="login-identifier">账号</Label>
<Input
autoComplete="username"
data-testid="login-identifier"
id="login-identifier"
onChange={(event) => setIdentifier(event.target.value)}
placeholder="邮箱或手机号"
value={identifier}
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2">
<div className="col-start-1 row-start-1">
<Label htmlFor="login-password">密码</Label>
</div>
<div className="col-span-2 row-start-2">
<PasswordInput
autoComplete="current-password"
data-testid="login-password"
id="login-password"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
value={password}
/>
</div>
<div className="col-start-2 row-start-1 justify-self-end">
<Link className="text-sm font-medium text-primary hover:underline" to="/forgot-password">
忘记密码?
</Link>
</div>
</div>
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600">{error}</div> : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit">
{submitting ? "登录中..." : "登录"}
</Button>
</form>
</div>
);
}
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() || !emailDeliveryReady}
type="submit"
variant="secondary"
>
{emailDeliveryChecking
? "检查邮件服务..."
: emailCodeSending
? "发送中..."
: emailCodeCooldown > 0
? `${emailCodeCooldown} 秒后重发`
: emailCodeRequested
? "重新发送验证码"
: "发送验证码"}
</Button>
</form>
{!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="login-email-delivery-status"
>
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
<span>
{emailDeliveryChecking
? "正在检查企业邮箱验证码服务。"
: emailDeliveryUnknown
? "暂时无法确认邮件服务,请使用密码登录或稍后刷新。"
: emailDeliveryUnavailable
? "邮件验证码暂不可用,请使用密码登录或联系管理员。"
: "邮件验证码暂不可用。"}
</span>
</div>
) : null}
<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-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm text-emerald-700" data-testid="login-email-code-message">
{emailCodeMessage}
</div>
) : null}
{emailCodeError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 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
className={pcWecomEntry ? "lg:p-8" : "lg:mx-auto lg:max-w-[460px]"}
data-testid="login-auth-panel"
title="海音达 OA"
subtitle={showWecomPrimary ? "企业微信环境优先免登" : "选择一种方式进入工作台"}
>
<div className="space-y-4">
{wecomNotice ? (
<div className={`rounded-lg border px-3.5 py-2.5 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div>
<div className="mt-1 leading-6">{wecomNotice.description}</div>
</div>
) : null}
{showWecomPrimary ? (
<div className="space-y-3" data-testid="login-wecom-primary-panel">
<Button className="w-full" data-testid="wecom-entry-primary" disabled={!wecomEntry} type="button" onClick={handleWecomLogin}>
{wecomConfigQuery.isLoading ? "准备中..." : "企业微信一键进入"}
</Button>
{wecomConfig && !wecomEntry ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
企业微信入口不可用,请用账号密码。
</div>
) : null}
{!showPasswordPanel ? (
<button
type="button"
className="mt-4 w-full text-sm font-medium text-primary"
onClick={() => setPasswordFallbackOpen(true)}
>
使用账号密码登录
</button>
) : null}
</div>
) : null}
{showWecomPrimary ? (
showPasswordPanel ? renderPasswordPanel("login-password-secondary-panel") : null
) : (
<div
className={pcWecomEntry
? "flex flex-col gap-5 lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,404px)] lg:gap-8"
: "mx-auto max-w-[404px] space-y-4"}
data-layout={pcWecomEntry ? "desktop-split" : "single-column"}
data-testid="login-browser-layout"
>
<section
className={pcWecomEntry ? "order-1 space-y-4 lg:order-2" : "space-y-4"}
data-testid="login-fallback-panel"
>
<div className="ux-command-note lg:hidden">
{pcWecomEntry
? "可使用企业微信扫码登录;企业邮箱验证码和账号密码作为备用方式。"
: "员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。"}
</div>
{pcWecomEntry ? (
<div className="hidden lg:block">
<h2 className="text-base font-semibold text-slate-950">备用登录</h2>
<p className="mt-1 text-sm leading-6 text-slate-500">使用账号密码或企业邮箱验证码。</p>
</div>
) : null}
<div className="auth-mode-switch" data-testid="login-mode-switch">
<button
aria-pressed={loginMode === "password"}
className={`auth-mode-switch__item ${loginMode === "password" ? "auth-mode-switch__item--active" : "auth-mode-switch__item--idle"}`}
data-testid="login-mode-password"
type="button"
onClick={() => selectLoginMode("password")}
>
账号密码
</button>
<button
aria-pressed={loginMode === "email_code"}
className={`auth-mode-switch__item ${loginMode === "email_code" ? "auth-mode-switch__item--active" : "auth-mode-switch__item--idle"}`}
data-testid="login-mode-email-code"
type="button"
onClick={() => selectLoginMode("email_code")}
>
邮箱验证码
</button>
</div>
{loginMode === "email_code"
? renderEmailCodePanel("login-email-code-panel")
: renderPasswordPanel("login-password-primary-panel")}
</section>
{renderBrowserEnterprisePanel()}
</div>
)}
</div>
</AuthPanel>
);
}