feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -0,0 +1,307 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user