547 行
21 KiB
TypeScript
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>
|
|
);
|
|
}
|