Files
UHD_Media_Tool/apps/web/app/components/settings/api-keys.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

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

261 lines
9.4 KiB
TypeScript

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="已签发的 Key"
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 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="撤销 Key"
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>
);
}