Files
UHD_Media_Tool/apps/web/app/routes/login.tsx
T

481 lines
18 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { AdminSetupRequest, LoginRequest, RecoveryCode, TotpCode } from "@umt/contracts";
import {
AlertCircle,
Clapperboard,
Eye,
EyeOff,
Fingerprint,
KeyRound,
LoaderCircle,
LockKeyhole,
ShieldCheck,
Terminal,
User,
} from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { z } from "zod";
import { isPasskeyCancel, login, loginWithPasskey, passkeySupported, setupAdmin, useSession } from "~/api/auth.ts";
import PrismLeak from "~/components/prism-leak.tsx";
import { ThemeToggle } from "~/components/theme-toggle.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Label } from "~/components/ui/label.tsx";
import { ApiRequestError } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "登录 · UHD 媒体自动化控制台" }];
type Mode = "setup" | "login" | "totp";
/** next 只接受站内控制台路径,防开放重定向 */
function safeNext(raw: string | null): string {
if (raw?.startsWith("/console") && !raw.startsWith("//")) return raw;
return "/console";
}
const SetupForm = AdminSetupRequest.extend({ confirm: z.string() }).refine((v) => v.password === v.confirm, {
message: "两次输入的密码不一致",
path: ["confirm"],
});
/** 后端错误码 → 文案(不直接透传后端 message,setup token 类除外) */
function errorText(err: unknown, mode: Mode | "passkey" | "recovery"): string {
if (!(err instanceof ApiRequestError)) return "请求失败,稍后重试";
switch (err.code) {
case "rate_limited":
return "失败次数过多,15 分钟后再试";
case "upstream_unavailable":
return "连不上服务端,确认它在运行";
case "unauthorized":
if (mode === "passkey") return "Passkey 校验没通过,换一把或用密码登录";
if (mode === "recovery") return "恢复码错误或已用过";
if (mode === "totp") return "验证码错误或已用过,等下一个验证码再试";
if (mode === "setup") return "初始化口令无效,用服务端日志里最新的一条";
return "用户名或密码错误";
case "validation_failed":
case "bad_request":
return "输入格式不正确";
case "conflict":
return "管理员已初始化,直接登录";
case "precondition_failed":
if (mode === "passkey") return "登录挑战已过期,再点一次";
return err.message || "登录失败";
default:
return err.message || "登录失败";
}
}
function IconInput({
icon: Icon,
trailing,
className,
...props
}: React.ComponentProps<typeof Input> & { icon: typeof User; trailing?: ReactNode }) {
return (
<div className="relative">
<Icon className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className={cn("h-12 rounded-lg pl-10 text-control md:text-control", trailing && "pr-11", className)}
{...props}
/>
{trailing ? <div className="absolute top-1/2 right-1.5 -translate-y-1/2">{trailing}</div> : null}
</div>
);
}
export default function LoginPage() {
const session = useSession();
const qc = useQueryClient();
const navigate = useNavigate();
const [params] = useSearchParams();
const next = safeNext(params.get("next"));
const [mode, setMode] = useState<Mode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [setupToken, setSetupToken] = useState("");
const [totp, setTotp] = useState("");
/** 二次验证这一步改用恢复码(验证器不在手边) */
const [useRecovery, setUseRecovery] = useState(false);
const [recovery, setRecovery] = useState("");
const [passkeyLoading, setPasskeyLoading] = useState(false);
// 安全上下文 + 支持 WebAuthn 才显示 Passkey 入口(挂载后再判断,避免与首屏渲染不一致)
const [canPasskey, setCanPasskey] = useState(false);
useEffect(() => setCanPasskey(passkeySupported()), []);
const [showPwd, setShowPwd] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const totpRef = useRef<HTMLInputElement>(null);
// 初始状态:未初始化 → 初始化向导;已登录 → 回跳
useEffect(() => {
if (!session.data) return;
if (session.data.authenticated && session.data.actor?.type === "admin") {
navigate(next, { replace: true });
return;
}
setMode((m) => (session.data.initialized ? (m === "setup" ? "login" : m) : "setup"));
}, [session.data, navigate, next]);
useEffect(() => {
if (mode === "totp") totpRef.current?.focus();
}, [mode]);
const done = async () => {
await qc.invalidateQueries({ queryKey: qk.session });
navigate(next, { replace: true });
};
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
setError(null);
setFieldErrors({});
if (mode === "setup") {
const parsed = SetupForm.safeParse({ setupToken: setupToken.trim(), username, password, confirm });
if (!parsed.success) {
const fe: Record<string, string> = {};
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
if (fe.setupToken) fe.setupToken = "初始化口令至少 16 个字符";
if (fe.username && !username.trim()) fe.username = "输入用户名";
setFieldErrors(fe);
return;
}
setLoading(true);
try {
await setupAdmin({ setupToken: parsed.data.setupToken, username, password });
await done();
} catch (err) {
if (err instanceof ApiRequestError && err.code === "conflict") setMode("login");
setError(errorText(err, "setup"));
} finally {
setLoading(false);
}
return;
}
const recoveryStep = mode === "totp" && useRecovery;
const body = {
username,
password,
totpCode: mode === "totp" && !useRecovery ? totp : undefined,
recoveryCode: recoveryStep ? recovery.trim() : undefined,
};
const schema =
mode !== "totp"
? LoginRequest
: useRecovery
? LoginRequest.extend({ recoveryCode: RecoveryCode })
: LoginRequest.extend({ totpCode: TotpCode });
const parsed = schema.safeParse(body);
if (!parsed.success) {
const fe: Record<string, string> = {};
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
if (fe.username) fe.username = "输入用户名";
if (fe.password) fe.password = "输入密码";
if (fe.recoveryCode) fe.recoveryCode = "恢复码是 10 位字母数字";
setFieldErrors(fe);
return;
}
setLoading(true);
try {
const r = await login(parsed.data);
if (r.status === "totp_required") {
setMode("totp");
setTotp("");
} else {
await done();
}
} catch (err) {
if (err instanceof ApiRequestError && err.code === "precondition_failed") {
setMode("setup");
setError("管理员尚未初始化");
} else {
setError(errorText(err, recoveryStep ? "recovery" : mode));
}
} finally {
setLoading(false);
}
};
const onPasskey = async () => {
if (loading || passkeyLoading) return;
setError(null);
setPasskeyLoading(true);
try {
const r = await loginWithPasskey();
if (r.status === "ok") await done();
} catch (err) {
if (!isPasskeyCancel(err)) setError(errorText(err, "passkey"));
} finally {
setPasskeyLoading(false);
}
};
const probing = session.isPending;
const disabled = probing || loading || passkeyLoading;
const showPasskey = mode === "login" && canPasskey && session.data?.passkeyEnabled === true;
const title = mode === "setup" ? "初始化管理员" : mode === "totp" ? "二次验证" : "登录控制台";
const subtitle =
mode === "setup"
? "首次启动:设置唯一的管理员账号。"
: mode === "totp"
? useRecovery
? "输入一个没用过的恢复码,用过即作废。"
: "输入身份验证器里的 6 位验证码。"
: "使用管理员账号登录,会话 12 小时有效。";
return (
<div className="relative isolate flex min-h-screen flex-col overflow-x-hidden bg-background">
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
<PrismLeak className="-z-10" />
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
</span>
<span className="text-xs font-bold tracking-wide text-foreground">UHD Media Tool</span>
</div>
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
<main className="mx-auto grid w-full max-w-6xl flex-1 items-center gap-10 px-4 py-10 md:px-6 lg:grid-cols-[minmax(0,1fr)_23.5rem] lg:py-16">
{/* 左侧品牌区(纯装饰,仅桌面) */}
<section className="hidden lg:block">
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">媒体自动化</p>
<h1 className="mt-3 max-w-xl font-display text-display-sm leading-tight font-semibold text-foreground">
订阅、下载、整理
</h1>
<p className="mt-4 max-w-lg text-lead text-muted-foreground">每一步都有记录。拿不准的会停下来等你决定。</p>
<ul className="mt-8 space-y-3 text-body text-muted-foreground">
<li className="flex items-center gap-2.5">
<ShieldCheck className="h-4 w-4 text-foreground/70" />
单管理员登录,外部程序走 API Key
</li>
<li className="flex items-center gap-2.5">
<LockKeyhole className="h-4 w-4 text-foreground/70" />
凭证加密存储,页面不回显
</li>
</ul>
</section>
<section className="pg-slab w-full rounded-2xl p-6 sm:p-7">
<h2 className="text-title font-semibold tracking-tight text-foreground">{title}</h2>
<p className="mt-1 text-caption text-muted-foreground">{subtitle}</p>
{mode === "setup" ? (
<div className="mt-4 flex gap-2.5 rounded-lg border border-border bg-foreground/[0.03] p-3 text-meta text-muted-foreground">
<Terminal className="mt-0.5 h-4 w-4 shrink-0" />
<p>
初始化口令在服务端日志里,用
<code className="mx-1 rounded-tight bg-foreground/[0.06] px-1 font-mono">
docker compose logs app | grep 'setup token'
</code>
查看;重启会生成新口令。
</p>
</div>
) : null}
{showPasskey ? (
<div className="mt-5 grid gap-3">
<Button type="button" size="lg" className="w-full" onClick={onPasskey} disabled={disabled}>
{passkeyLoading ? <LoaderCircle className="animate-spin" /> : <Fingerprint />}用 Passkey 登录
</Button>
<div className="flex items-center gap-3 text-meta text-muted-foreground">
<span className="h-px flex-1 bg-border" />
或用密码
<span className="h-px flex-1 bg-border" />
</div>
</div>
) : null}
<form className={cn(showPasskey ? "mt-3" : "mt-5", "space-y-3")} onSubmit={onSubmit} noValidate>
{mode === "setup" ? (
<Field id="setupToken" label="初始化口令" error={fieldErrors.setupToken}>
<IconInput
id="setupToken"
icon={KeyRound}
autoComplete="off"
value={setupToken}
onChange={(e) => setSetupToken(e.target.value)}
disabled={disabled}
aria-invalid={!!fieldErrors.setupToken}
placeholder="从服务端日志复制"
/>
</Field>
) : null}
<Field id="username" label="用户名" error={fieldErrors.username}>
<IconInput
id="username"
icon={User}
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
readOnly={mode === "totp"}
disabled={disabled}
aria-invalid={!!fieldErrors.username}
/>
</Field>
<Field id="password" label="密码" error={fieldErrors.password}>
<IconInput
id="password"
icon={LockKeyhole}
type={showPwd ? "text" : "password"}
autoComplete={mode === "setup" ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
readOnly={mode === "totp"}
disabled={disabled}
aria-invalid={!!fieldErrors.password}
placeholder={mode === "setup" ? "至少 10 个字符" : undefined}
trailing={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={showPwd ? "隐藏密码" : "显示密码"}
aria-pressed={showPwd}
onClick={() => setShowPwd((v) => !v)}
>
{showPwd ? <EyeOff /> : <Eye />}
</Button>
}
/>
</Field>
{mode === "setup" ? (
<Field id="confirm" label="确认密码" error={fieldErrors.confirm}>
<IconInput
id="confirm"
icon={LockKeyhole}
type={showPwd ? "text" : "password"}
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
disabled={disabled}
aria-invalid={!!fieldErrors.confirm}
/>
</Field>
) : null}
{mode === "totp" && useRecovery ? (
<Field id="recovery" label="恢复码" error={fieldErrors.recoveryCode}>
<IconInput
id="recovery"
icon={KeyRound}
autoComplete="off"
maxLength={16}
value={recovery}
onChange={(e) => setRecovery(e.target.value)}
disabled={disabled}
aria-invalid={!!fieldErrors.recoveryCode}
className="font-mono tracking-widest uppercase"
placeholder="XXXXX-XXXXX"
/>
</Field>
) : null}
{mode === "totp" && !useRecovery ? (
<Field id="totp" label="验证码" error={fieldErrors.totpCode}>
<IconInput
ref={totpRef}
id="totp"
icon={ShieldCheck}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ""))}
disabled={disabled}
aria-invalid={!!fieldErrors.totpCode}
className="font-mono tracking-[0.4em]"
placeholder="000000"
/>
</Field>
) : null}
{/* 固定高度错误占位,报错时不跳 */}
<div className="min-h-5" aria-live="polite">
{error ? (
<p role="alert" className="flex items-center gap-1.5 text-caption text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</p>
) : null}
</div>
<Button
type="submit"
size="lg"
variant={showPasskey ? "outline" : "default"}
className="w-full"
disabled={disabled}
>
{loading || probing ? <LoaderCircle className="animate-spin" /> : null}
{mode === "setup" ? "初始化并登录" : mode === "totp" ? "验证" : "登录"}
</Button>
{mode === "totp" ? (
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="ghost"
disabled={loading}
onClick={() => {
setUseRecovery((v) => !v);
setTotp("");
setRecovery("");
setError(null);
setFieldErrors({});
}}
>
{useRecovery ? "改用验证码" : "用恢复码"}
</Button>
<Button
type="button"
variant="ghost"
disabled={loading}
onClick={() => {
setMode("login");
setTotp("");
setRecovery("");
setUseRecovery(false);
setError(null);
}}
>
返回修改账号
</Button>
</div>
) : null}
{session.isError ? (
<p className="text-center text-meta text-muted-foreground">
读取登录状态失败,
<button type="button" className="cursor-pointer underline" onClick={() => session.refetch()}>
重试
</button>
</p>
) : null}
</form>
</section>
</main>
</div>
);
}
function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: ReactNode }) {
return (
<div className="grid gap-1.5">
<Label htmlFor={id} className="text-caption text-muted-foreground">
{label}
</Label>
{children}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
</div>
);
}