Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
481 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|