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,260 @@
|
||||
import { type ApiKey, ApiKeyCreate, type ApiScope } from "@umt/contracts";
|
||||
import { Check, Copy, LoaderCircle, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// ─── API Key ───
|
||||
|
||||
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
|
||||
{ value: "read", label: "read", hint: "只读查询全部数据" },
|
||||
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
|
||||
{ value: "organize.execute", label: "organize.execute", hint: "发起整理" },
|
||||
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
|
||||
];
|
||||
|
||||
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Dialog open={!!plaintext} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key 已创建</DialogTitle>
|
||||
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
|
||||
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="复制"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext ?? "");
|
||||
setCopied(true);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,手动选中复制");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const keys = useApiKeys();
|
||||
const create = useCreateApiKey();
|
||||
const revoke = useRevokeApiKey();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [scopes, setScopes] = useState<ApiScope[]>(["read"]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [plaintext, setPlaintext] = useState<string | null>(null);
|
||||
const [revoking, setRevoking] = useState<ApiKey | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
if (fe.name) fe.name = "名称必填,80 字以内";
|
||||
if (fe.scopes) fe.scopes = "至少选一个权限范围";
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const r = await create.mutateAsync(parsed.data);
|
||||
setOpen(false);
|
||||
setName("");
|
||||
setScopes(["read"]);
|
||||
setPlaintext(r.plaintextKey);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "创建失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="api-keys"
|
||||
anchor="keys"
|
||||
title="API Key"
|
||||
description="签发给外部程序(媒体服务器插件、脚本)的 Key,按权限范围授权。控制台走登录会话,不用 Key;浏览器本机 Token 在「账户与安全」。"
|
||||
plain
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus />
|
||||
创建 API Key
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ScopeLegend />
|
||||
<DataTable
|
||||
rows={keys.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={keys.isPending}
|
||||
error={keys.error}
|
||||
onRetry={() => keys.refetch()}
|
||||
empty={{ title: "还没有 API Key" }}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className={cn("font-medium", r.revokedAt && "text-muted-foreground line-through")}>{r.name}</p>
|
||||
<p className="text-meta text-muted-foreground">创建于 {formatDateTime(r.createdAt)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "scopes",
|
||||
header: "权限",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.scopes.map((s) => (
|
||||
<code key={s} className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5 text-meta">
|
||||
{s}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) },
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => (
|
||||
<StatusBadge tone={r.revokedAt ? "neutral" : "success"}>{r.revokedAt ? "已撤销" : "有效"}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (r) =>
|
||||
r.revokedAt ? null : (
|
||||
<Button variant="ghost" size="sm" className="hover:text-destructive" onClick={() => setRevoking(r)}>
|
||||
撤销
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Dialog open={open} onOpenChange={(o) => !create.isPending && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建 API Key</DialogTitle>
|
||||
<DialogDescription>创建后明文只显示一次。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="apikey-form" onSubmit={submit} noValidate className="grid gap-1">
|
||||
<FormField label="名称" htmlFor="ak-name" error={errors.name} required>
|
||||
<Input
|
||||
id="ak-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-invalid={!!errors.name}
|
||||
placeholder="emby-plugin"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="权限范围" error={errors.scopes}>
|
||||
<div className="grid gap-1.5">
|
||||
{SCOPES.map((s) => {
|
||||
const on = scopes.includes(s.value);
|
||||
return (
|
||||
<label
|
||||
key={s.value}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-current"
|
||||
checked={on}
|
||||
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
|
||||
/>
|
||||
<code className="text-caption">{s.label}</code>
|
||||
<span className="text-meta text-muted-foreground">{s.hint}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormField>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={create.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="apikey-form" disabled={create.isPending}>
|
||||
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreatedKeyDialog plaintext={plaintext} onClose={() => setPlaintext(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!revoking}
|
||||
onOpenChange={(o) => !o && setRevoking(null)}
|
||||
title={`撤销「${revoking?.name ?? ""}」?`}
|
||||
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
|
||||
confirmLabel="撤销"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!revoking) return;
|
||||
try {
|
||||
await revoke.mutateAsync(revoking.id);
|
||||
toast.success("已撤销");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "撤销失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeLegend() {
|
||||
return (
|
||||
<dl className="mb-3 grid gap-x-4 gap-y-1 text-meta sm:grid-cols-[auto_1fr]">
|
||||
{SCOPES.map((s) => (
|
||||
<div key={s.value} className="contents">
|
||||
<dt>
|
||||
<code className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</code>
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">{s.hint}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user