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]>
308 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|