Files
UHD_Media_Tool/apps/web/app/components/settings/pi-oauth.tsx
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:34:51 +08:00

308 lines
11 KiB
TypeScript

import type { PiOAuthMode, PiOAuthStartResult, PiOAuthState, PiProviderInfo, PiUpstream } from "@umt/contracts";
import { Check, Copy, ExternalLink, LoaderCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { completePiOAuth, useInvalidatePi, useStartPiOAuth } from "~/api/pi.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 订阅登录:start → browser 粘回地址栏 URL / device 前端轮询 complete → 终态。
// 登录会话只在服务端内存里,15 分钟过期;失败、过期、被取消都可以重新开始。
const MODE_LABEL: Record<PiOAuthMode, string> = { device: "验证码", browser: "浏览器授权" };
const END_LABEL: Record<
Exclude<PiOAuthState, "pending">,
{ label: string; tone: "success" | "destructive" | "warning" }
> = {
succeeded: { label: "已登录", tone: "success" },
failed: { label: "登录失败", tone: "destructive" },
expired: { label: "登录会话已过期", tone: "warning" },
cancelled: { label: "登录已取消", tone: "warning" },
};
function CopyButton({ text, label }: { text: string; label: string }) {
const [done, setDone] = useState(false);
return (
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={`复制${label}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setDone(true);
toast.success("已复制");
} catch {
toast.error("复制失败,手动选中复制");
}
}}
>
{done ? <Check /> : <Copy />}
</Button>
);
}
function useCountdown(expiresAt: string | null) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!expiresAt) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [expiresAt]);
if (!expiresAt) return null;
const left = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000));
return left;
}
export function PiOAuthLogin({
upstream,
provider,
onSucceeded,
}: {
upstream: PiUpstream;
provider: PiProviderInfo | undefined;
onSucceeded?: () => void;
}) {
const start = useStartPiOAuth();
const invalidate = useInvalidatePi();
const modes = provider?.oauth?.modes ?? ["browser"];
const [mode, setMode] = useState<PiOAuthMode>(modes[0] ?? "browser");
const [session, setSession] = useState<PiOAuthStartResult | null>(null);
const [state, setState] = useState<PiOAuthState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [progress, setProgress] = useState<string[]>([]);
const [pasted, setPasted] = useState("");
const [submitting, setSubmitting] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const left = useCountdown(state === "pending" ? (session?.expiresAt ?? null) : null);
const termsNote = provider?.oauth?.termsNote ?? provider?.termsNote ?? null;
useEffect(() => () => abortRef.current?.abort(), []);
const finish = (s: PiOAuthState, msg: string | null) => {
setState(s);
setMessage(msg);
invalidate();
if (s === "succeeded") {
toast.success(`${upstream.name}已登录`);
onSucceeded?.();
}
};
// device:反复调 complete(每次服务端最多等 25 秒)直到终态或本地判定过期
const poll = async (s: PiOAuthStartResult) => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
while (!ac.signal.aborted) {
if (Date.now() > new Date(s.expiresAt).getTime()) {
finish("expired", "授权已超时。");
return;
}
try {
const r = await completePiOAuth(upstream.id, { sessionId: s.sessionId, waitMs: 25_000 }, ac.signal);
setProgress(r.progress);
if (r.state !== "pending") {
finish(r.state, r.message);
return;
}
} catch (err) {
if (ac.signal.aborted) return;
finish("failed", errorMessage(err, "轮询登录状态失败"));
return;
}
}
};
const begin = async () => {
abortRef.current?.abort();
setMessage(null);
setProgress([]);
setPasted("");
try {
const r = await start.mutateAsync({ id: upstream.id, body: { mode } });
setSession(r);
setState(r.state);
setMessage(r.message);
if (r.state === "pending" && r.mode === "device") void poll(r);
} catch (err) {
setSession(null);
setState(null);
toast.error(errorMessage(err, "无法开始登录"));
}
};
const submitPasted = async (e: React.FormEvent) => {
e.preventDefault();
if (!session || !pasted.trim()) return;
setSubmitting(true);
try {
const r = await completePiOAuth(upstream.id, {
sessionId: session.sessionId,
pastedUrlOrCode: pasted.trim(),
waitMs: 25_000,
});
setProgress(r.progress);
if (r.state === "pending") setMessage("还在处理,稍后再提交一次。");
else finish(r.state, r.message);
} catch (err) {
toast.error(errorMessage(err, "提交失败"));
} finally {
setSubmitting(false);
}
};
const pending = state === "pending" && !!session;
const ended = state && state !== "pending" ? END_LABEL[state] : null;
const mmss = left === null ? "" : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
return (
<div className="grid gap-3">
{termsNote ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-foreground">
{termsNote}
</p>
) : null}
{!pending ? (
<div className="grid gap-3">
{modes.length > 1 ? (
<fieldset className="grid gap-1.5">
<legend className="mb-1 text-caption font-medium">登录方式</legend>
<div className="flex flex-wrap gap-2">
{modes.map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={cn(
"rounded-full border px-3 py-1 text-caption transition-colors duration-micro",
mode === m
? "border-foreground/30 bg-foreground/[0.08] text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.04]",
)}
>
{MODE_LABEL[m]}
</button>
))}
</div>
</fieldset>
) : null}
{ended ? (
<div className="flex flex-wrap items-center gap-2 text-caption">
<StatusBadge tone={ended.tone}>{ended.label}</StatusBadge>
{message ? <span className="text-muted-foreground">{message}</span> : null}
</div>
) : null}
{state !== "succeeded" ? (
<div>
<Button size="sm" onClick={begin} disabled={start.isPending}>
{start.isPending ? <LoaderCircle className="animate-spin" /> : null}
{ended ? "重新登录" : upstream.status === "expired" ? "重新登录" : "开始登录"}
</Button>
</div>
) : null}
</div>
) : session.mode === "device" ? (
<div className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground">
在任意设备打开验证地址,输入下面的验证码。授权后这里自动完成。
</p>
<div className="flex items-center gap-2">
<code className="rounded-md bg-muted px-3 py-1.5 font-mono text-lead tracking-widest select-all">
{session.userCode}
</code>
{session.userCode ? <CopyButton text={session.userCode} label="验证码" /> : null}
</div>
{session.verificationUrl ? (
<a
href={session.verificationUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-caption break-all underline"
>
{session.verificationUrl}
<ExternalLink className="size-3.5 shrink-0" />
</a>
) : null}
{session.instructions ? <p className="text-meta text-muted-foreground">{session.instructions}</p> : null}
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
等待授权 · 剩余 {mmss}
{progress.length ? ` · ${progress[progress.length - 1]}` : ""}
</p>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
abortRef.current?.abort();
setState(null);
setSession(null);
}}
>
取消
</Button>
</div>
</div>
) : (
<form onSubmit={submitPasted} noValidate className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground">
在浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开属正常,把地址栏里的完整 URL 粘贴到下面。
</p>
{session.authorizeUrl ? (
<div className="flex min-w-0 items-center gap-2">
<a
href={session.authorizeUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex min-w-0 items-center gap-1 text-caption underline"
>
<span className="truncate">打开授权链接</span>
<ExternalLink className="size-3.5 shrink-0" />
</a>
<CopyButton text={session.authorizeUrl} label="授权链接" />
</div>
) : null}
<FormField label="地址栏 URL" htmlFor={`oauth-paste-${upstream.id}`} hint={`剩余 ${mmss}`}>
<Input
id={`oauth-paste-${upstream.id}`}
value={pasted}
onChange={(e) => setPasted(e.target.value)}
placeholder="http://localhost:…/callback?code=…&state=…"
autoComplete="off"
disabled={submitting}
/>
</FormField>
{message ? <p className="text-meta text-muted-foreground">{message}</p> : null}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={submitting || !pasted.trim()}>
{submitting ? <LoaderCircle className="animate-spin" /> : null}
完成登录
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setState(null);
setSession(null);
}}
>
取消
</Button>
</div>
</form>
)}
</div>
);
}