- 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]>
261 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|