238 行
10 KiB
TypeScript
238 行
10 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
|
import { isApiError } from "../api/types";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Label } from "../components/ui/label";
|
|
import { isWecomWebView } from "../lib/runtimeContext";
|
|
import {
|
|
buildWecomLoginUrl,
|
|
resolveWecomBindNotice,
|
|
resolveWecomEntryStatus,
|
|
resolveWecomLoginNotice,
|
|
type WecomNotice,
|
|
} from "../lib/wecomEntry";
|
|
|
|
function resolveRedirect(searchParams: URLSearchParams) {
|
|
const redirect = searchParams.get("redirect");
|
|
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
|
|
return "/portal";
|
|
}
|
|
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 } = useAuth();
|
|
|
|
const [identifier, setIdentifier] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const redirectTo = resolveRedirect(searchParams);
|
|
const isWecomContext = isWecomWebView();
|
|
|
|
const ssoProvidersQuery = useQuery({
|
|
queryKey: ["auth", "sso-providers"],
|
|
queryFn: () => fetchSsoProviders(),
|
|
staleTime: 60_000,
|
|
});
|
|
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
|
|
|
|
const wecomConfigQuery = useQuery({
|
|
queryKey: ["auth", "wecom-config"],
|
|
queryFn: () => fetchWecomConfig(),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
const wecomConfig = wecomConfigQuery.data;
|
|
const wecomStatus = resolveWecomEntryStatus(wecomConfig);
|
|
const wecomEntry = wecomConfig?.entryEnabled ? wecomConfig : null;
|
|
const primaryMode = isWecomContext && wecomEntry?.loginUrl ? "wecom" : "password";
|
|
const wecomNotice = useMemo(
|
|
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
|
|
[searchParams],
|
|
);
|
|
|
|
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(mapApiErrorMessage(err.code, err.details, err.message));
|
|
} else if (err instanceof TypeError) {
|
|
setError("无法连接后端服务,请确认 server 已启动(http://127.0.0.1:3000)。");
|
|
} else {
|
|
setError("登录失败,请稍后重试。");
|
|
}
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
function handleWecomLogin() {
|
|
if (!wecomEntry?.loginUrl) {
|
|
return;
|
|
}
|
|
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo));
|
|
}
|
|
|
|
function renderPasswordPanel(testId: string, primary: boolean) {
|
|
return (
|
|
<div className="space-y-4 rounded-[1.5rem] border border-slate-200 bg-white p-6 shadow-sm" data-testid={testId}>
|
|
<div>
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{primary ? "推荐入口" : "回退入口"}</div>
|
|
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用账号密码登录</h2>
|
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
|
{primary
|
|
? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。"
|
|
: "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"}
|
|
</p>
|
|
</div>
|
|
|
|
<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="space-y-2">
|
|
<Label htmlFor="login-password">密码</Label>
|
|
<Input
|
|
autoComplete="current-password"
|
|
data-testid="login-password"
|
|
id="login-password"
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
placeholder="请输入密码"
|
|
type="password"
|
|
value={password}
|
|
/>
|
|
</div>
|
|
|
|
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
|
|
|
|
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primary ? "default" : "secondary"}>
|
|
{submitting ? "登录中..." : "登录并进入工作台"}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="card-std overflow-hidden border-slate-200 p-0 shadow-md">
|
|
<div className="border-b border-slate-200 bg-[radial-gradient(circle_at_8%_0%,rgba(0,57,138,0.12),transparent_16rem),linear-gradient(135deg,#ffffff,#e6ecf4)] px-6 py-6 sm:px-8 sm:py-7">
|
|
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
|
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900">登录海音达工作台</h1>
|
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
|
|
企业微信负责进入与提醒,真正的审批、项目、周报和团队留痕都在海音达工作台内完成。
|
|
</p>
|
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-slate-600">
|
|
{isWecomContext
|
|
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台;失败时可随时回退到账号密码登录。"
|
|
: "当前为浏览器场景,推荐直接使用账号密码登录;企业微信入口会作为次级入口提供。"}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-5 px-6 py-6 sm:px-8 sm:py-7">
|
|
{wecomNotice ? (
|
|
<div className={`rounded-2xl border px-4 py-3 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}
|
|
|
|
<div className="grid gap-4">
|
|
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
|
|
<div className="rounded-[1.5rem] border border-primary/20 bg-primary/5 p-6" data-testid="login-wecom-primary-panel">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">推荐入口</div>
|
|
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用企业微信进入工作台</h2>
|
|
<p className="mt-2 text-sm leading-6 text-slate-600">当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。</p>
|
|
<Button className="mt-5 w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
|
|
使用企业微信进入工作台
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{renderPasswordPanel(
|
|
primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel",
|
|
primaryMode === "password",
|
|
)}
|
|
|
|
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
|
|
<div className="rounded-[1.5rem] border border-slate-200 bg-slate-50/70 p-6">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">补充入口</div>
|
|
<h2 className="mt-2 text-lg font-semibold text-slate-900">其他进入方式</h2>
|
|
<p className="mt-2 text-sm leading-6 text-slate-600">入口失败时请优先选择可理解、可回退的方式继续进入。</p>
|
|
<div className="mt-4 grid gap-3">
|
|
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
|
|
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
|
使用企业微信进入工作台
|
|
</Button>
|
|
) : null}
|
|
|
|
{oidcProvider ? (
|
|
<Button
|
|
className="w-full"
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
|
}}
|
|
>
|
|
使用{oidcProvider.label}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
|
<div className="font-medium text-slate-900">企业微信入口状态:{wecomStatus.label}</div>
|
|
<div className="mt-1 leading-6">{wecomStatus.description}</div>
|
|
{wecomStatus.reasonLabels.length ? (
|
|
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-slate-500">
|
|
{wecomStatus.reasonLabels.map((label) => (
|
|
<li key={label}>{label}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|