Files
UHD_Media_Tool/apps/web/app/components/settings/account-passkeys.tsx
T

210 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.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="删除 Passkey"
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 paged = useListPagination(items, { resetKey: String(disabled) });
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">
{paged.items?.map((p) => (
<PasskeyRow key={p.id} item={p} />
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
)}
{items.length > 0 ? <Pagination {...paged.pagination} /> : null}
{!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>
);
}