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 & { icon: typeof User; trailing?: ReactNode }) { return (
{trailing ?
{trailing}
: null}
); } 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("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(null); const [fieldErrors, setFieldErrors] = useState>({}); const [loading, setLoading] = useState(false); const totpRef = useRef(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 = {}; 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 = {}; 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 (
UHD Media Tool
{/* 左侧品牌区(纯装饰,仅桌面) */}

媒体自动化

订阅、下载、整理

每一步都有记录。拿不准的会停下来等你决定。

  • 单管理员登录,外部程序走 API Key
  • 凭证加密存储,页面不回显

{title}

{subtitle}

{mode === "setup" ? (

初始化口令在服务端日志里,用 docker compose logs app | grep 'setup token' 查看;重启会生成新口令。

) : null} {showPasskey ? (
或用密码
) : null}
{mode === "setup" ? ( setSetupToken(e.target.value)} disabled={disabled} aria-invalid={!!fieldErrors.setupToken} placeholder="从服务端日志复制" /> ) : null} setUsername(e.target.value)} readOnly={mode === "totp"} disabled={disabled} aria-invalid={!!fieldErrors.username} /> setPassword(e.target.value)} readOnly={mode === "totp"} disabled={disabled} aria-invalid={!!fieldErrors.password} placeholder={mode === "setup" ? "至少 10 个字符" : undefined} trailing={ } /> {mode === "setup" ? ( setConfirm(e.target.value)} disabled={disabled} aria-invalid={!!fieldErrors.confirm} /> ) : null} {mode === "totp" && useRecovery ? ( setRecovery(e.target.value)} disabled={disabled} aria-invalid={!!fieldErrors.recoveryCode} className="font-mono tracking-widest uppercase" placeholder="XXXXX-XXXXX" /> ) : null} {mode === "totp" && !useRecovery ? ( setTotp(e.target.value.replace(/\D/g, ""))} disabled={disabled} aria-invalid={!!fieldErrors.totpCode} className="font-mono tracking-[0.4em]" placeholder="000000" /> ) : null} {/* 固定高度错误占位,报错时不跳 */}
{error ? (

{error}

) : null}
{mode === "totp" ? (
) : null} {session.isError ? (

读取登录状态失败,

) : null}
); } function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: ReactNode }) { return (
{children} {error ?

{error}

: null}
); }