163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { Alert, Button, CloseButton, Spinner } from "@heroui/react";
|
|
import { Fingerprint, TrashBin } from "@gravity-ui/icons";
|
|
import { authClient } from "@/lib/auth-client";
|
|
|
|
type Passkey = {
|
|
id: string;
|
|
name?: string | null;
|
|
createdAt: Date | string;
|
|
deviceType?: string | null;
|
|
};
|
|
|
|
export default function SettingsPage() {
|
|
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [adding, setAdding] = useState(false);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
|
|
const loadPasskeys = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await authClient.passkey.listUserPasskeys();
|
|
setPasskeys((res.data as Passkey[] | null) ?? []);
|
|
} catch {
|
|
setError("获取 Passkey 列表失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadPasskeys();
|
|
}, [loadPasskeys]);
|
|
|
|
const handleAdd = async () => {
|
|
setError("");
|
|
setSuccess("");
|
|
setAdding(true);
|
|
try {
|
|
const res = await authClient.passkey.addPasskey();
|
|
if (res?.error) {
|
|
setError(res.error.message ?? "注册 Passkey 失败");
|
|
} else {
|
|
setSuccess("Passkey 注册成功");
|
|
await loadPasskeys();
|
|
}
|
|
} catch {
|
|
setError("注册 Passkey 失败,请重试");
|
|
} finally {
|
|
setAdding(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
setError("");
|
|
setSuccess("");
|
|
setDeletingId(id);
|
|
try {
|
|
const res = await authClient.passkey.deletePasskey({ id });
|
|
if (res?.error) {
|
|
setError(res.error.message ?? "删除 Passkey 失败");
|
|
} else {
|
|
setPasskeys((prev) => prev.filter((p) => p.id !== id));
|
|
setSuccess("Passkey 已删除");
|
|
}
|
|
} catch {
|
|
setError("删除 Passkey 失败,请重试");
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-foreground">账号设置</h1>
|
|
<p className="mt-0.5 text-sm text-muted">管理您的安全凭据</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert status="danger">
|
|
<Alert.Indicator />
|
|
<Alert.Content>
|
|
<Alert.Description>{error}</Alert.Description>
|
|
</Alert.Content>
|
|
<CloseButton onPress={() => setError("")} />
|
|
</Alert>
|
|
)}
|
|
{success && (
|
|
<Alert status="success">
|
|
<Alert.Indicator />
|
|
<Alert.Content>
|
|
<Alert.Description>{success}</Alert.Description>
|
|
</Alert.Content>
|
|
<CloseButton onPress={() => setSuccess("")} />
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Passkey section */}
|
|
<div className="rounded-xl border border-border bg-surface-secondary">
|
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
|
|
<Fingerprint className="size-5 text-accent" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">Passkey</p>
|
|
<p className="text-xs text-muted">使用设备生物识别或 PIN 免密登录</p>
|
|
</div>
|
|
</div>
|
|
<Button size="sm" variant="secondary" isDisabled={adding} onPress={handleAdd}>
|
|
{adding ? <Spinner size="sm" /> : "添加 Passkey"}
|
|
</Button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-8">
|
|
<Spinner />
|
|
</div>
|
|
) : passkeys.length === 0 ? (
|
|
<div className="py-8 text-center text-sm text-muted">尚未添加任何 Passkey</div>
|
|
) : (
|
|
<ul className="divide-y divide-border">
|
|
{passkeys.map((pk) => (
|
|
<li key={pk.id} className="flex items-center justify-between px-5 py-3.5">
|
|
<div className="flex items-center gap-3">
|
|
<Fingerprint className="size-4 shrink-0 text-muted" />
|
|
<div>
|
|
<p className="text-sm font-medium text-foreground">
|
|
{pk.name ?? pk.deviceType ?? "Passkey"}
|
|
</p>
|
|
<p className="text-xs text-muted">
|
|
添加于{" "}
|
|
{new Date(pk.createdAt).toLocaleString("zh-CN", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
isIconOnly
|
|
size="sm"
|
|
variant="danger-soft"
|
|
isDisabled={deletingId === pk.id}
|
|
onPress={() => handleDelete(pk.id)}
|
|
>
|
|
{deletingId === pk.id ? <Spinner size="sm" /> : <TrashBin className="size-4" />}
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|