Files
UHD_Media_Tool/apps/web/app/components/settings/account-passkeys.tsx
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
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]>
2026-09-28 21:34:51 +08:00

206 lines
6.9 KiB
TypeScript

import type { PasskeyInfo } from "@umt/contracts";
import { Check, KeyRound, LoaderCircle, Pencil, Plus, Trash2, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
isPasskeyCancel,
passkeySupported,
useDeletePasskey,
usePasskeys,
useRegisterPasskey,
useRenamePasskey,
} from "~/api/auth.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
// Passkey:登记(可以多把,各自命名)、改名、删除。登录页有 Passkey 时显示"用 Passkey 登录"。
/** 按浏览器平台给个默认名,方便区分 */
function defaultName(): string {
const ua = typeof navigator === "undefined" ? "" : navigator.userAgent;
if (/iPhone|iPad/.test(ua)) return "iPhone / iPad";
if (/Mac OS X/.test(ua)) return "Mac";
if (/Android/.test(ua)) return "Android";
if (/Windows/.test(ua)) return "Windows";
return "Passkey";
}
function PasskeyRow({ item }: { item: PasskeyInfo }) {
const rename = useRenamePasskey();
const del = useDeletePasskey();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(item.name);
const save = async (e: React.FormEvent) => {
e.preventDefault();
const v = name.trim();
if (!v || v === item.name) return setEditing(false);
try {
await rename.mutateAsync({ id: item.id, name: v });
setEditing(false);
} catch (err) {
toast.error(errorMessage(err));
}
};
return (
<li className="flex min-h-12 items-center gap-3 py-2">
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
{editing ? (
<form onSubmit={save} className="flex min-w-0 flex-1 items-center gap-1.5">
<Input
aria-label="Passkey 名称"
value={name}
maxLength={60}
autoFocus
onChange={(e) => setName(e.target.value)}
className="h-8 max-w-64"
/>
<Button type="submit" size="icon-sm" variant="ghost" aria-label="保存名称" disabled={rename.isPending}>
{rename.isPending ? <LoaderCircle className="animate-spin" /> : <Check />}
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="取消改名"
onClick={() => {
setName(item.name);
setEditing(false);
}}
>
<X />
</Button>
</form>
) : (
<div className="min-w-0 flex-1">
<p className="truncate text-caption font-medium">{item.name}</p>
<p className="text-meta text-muted-foreground">
{formatDateTime(item.createdAt)} 登记 ·{" "}
{item.lastUsedAt ? `${formatDateTime(item.lastUsedAt)} 用过` : "还没用过"}
{item.backedUp ? " · 已同步到云端钥匙串" : ""}
</p>
</div>
)}
{editing ? null : (
<div className="flex shrink-0 gap-1">
<Button size="icon-sm" variant="ghost" aria-label={`重命名 ${item.name}`} onClick={() => setEditing(true)}>
<Pencil />
</Button>
<ConfirmDialog
trigger={
<Button size="icon-sm" variant="ghost" aria-label={`删除 ${item.name}`}>
<Trash2 />
</Button>
}
title={`删除 Passkey「${item.name}」?`}
description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(item.id);
toast.success("Passkey 已删除");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
</div>
)}
</li>
);
}
export function PasskeyBlock({ disabled }: { disabled: boolean }) {
const list = usePasskeys(!disabled);
const register = useRegisterPasskey();
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const supported = passkeySupported();
const items = list.data?.items ?? [];
const add = async (e: React.FormEvent) => {
e.preventDefault();
try {
const r = await register.mutateAsync(name.trim() || defaultName());
toast.success(`已登记「${r.name}」`);
setAdding(false);
setName("");
} catch (err) {
if (isPasskeyCancel(err)) return;
toast.error(errorMessage(err, "登记失败"));
}
};
let body: React.ReactNode;
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
else
body = (
<div className="grid gap-3">
{list.isPending ? (
<p className="text-caption text-muted-foreground">读取中…</p>
) : items.length ? (
<ul className="divide-y divide-border/60">
{items.map((p) => (
<PasskeyRow key={p.id} item={p} />
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
)}
{!supported ? (
<p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p>
) : adding ? (
<form onSubmit={add} className="flex max-w-md flex-wrap items-center gap-2">
<Input
aria-label="新 Passkey 的名称"
placeholder={defaultName()}
value={name}
maxLength={60}
autoFocus
onChange={(e) => setName(e.target.value)}
className="min-w-0 flex-1"
/>
<Button type="submit" size="sm" disabled={register.isPending}>
{register.isPending ? <LoaderCircle className="animate-spin" /> : null}
继续
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setAdding(false)}
disabled={register.isPending}
>
取消
</Button>
</form>
) : (
<div>
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus />
添加 Passkey
</Button>
</div>
)}
</div>
);
return (
<SettingsBlock
section="account"
anchor="passkeys"
title="Passkey"
description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。"
>
{body}
</SettingsBlock>
);
}