Files
helios-evcs/apps/web/app/dashboard/settings/page.tsx

439 lines
17 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.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { Fingerprint, Lock, Pencil, Person, TrashBin, Xmark, Check } from "@gravity-ui/icons";
import { authClient, useSession } from "@/lib/auth-client";
type Passkey = {
id: string;
name?: string | null;
createdAt: Date | string;
deviceType?: string | null;
};
export default function SettingsPage() {
const { data: session, refetch: refetchSession } = useSession();
// ── Profile ──────────────────────────────────────────────────────────────
const [profileName, setProfileName] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
// sync name from session once loaded
useEffect(() => {
if (session?.user.name) setProfileName(session.user.name);
}, [session?.user.name]);
const handleSaveProfile = async () => {
setSavingProfile(true);
try {
const res = await authClient.updateUser({ name: profileName.trim() });
if (res?.error) {
toast.danger(res.error.message ?? "保存失败");
} else {
toast.success("显示名称已更新");
await refetchSession();
}
} catch {
toast.danger("保存失败,请重试");
} finally {
setSavingProfile(false);
}
};
// ── Password ─────────────────────────────────────────────────────────────
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [savingPw, setSavingPw] = useState(false);
const [pwError, setPwError] = useState("");
const [pwSuccess, setPwSuccess] = useState("");
const handleChangePassword = async () => {
setPwError("");
setPwSuccess("");
if (newPassword !== confirmPassword) {
setPwError("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setPwError("新密码至少需要 8 位");
return;
}
setSavingPw(true);
try {
const res = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: false,
});
if (res?.error) {
setPwError(res.error.message ?? "修改密码失败");
} else {
setPwSuccess("密码已修改成功");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
} catch {
setPwError("修改密码失败,请重试");
} finally {
setSavingPw(false);
}
};
// ── Passkey ───────────────────────────────────────────────────────────────
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [loading, setLoading] = useState(true);
const [addingName, setAddingName] = useState<string | null>(null); // null = collapsed
const [registering, setRegistering] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [renameSaving, setRenameSaving] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const loadPasskeys = useCallback(async () => {
setLoading(true);
try {
const res = await authClient.passkey.listUserPasskeys();
setPasskeys((res.data as Passkey[] | null) ?? []);
} catch {
toast.danger("获取 Passkey 列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadPasskeys();
}, [loadPasskeys]);
const handleStartAdd = () => {
setAddingName("");
};
const handleCancelAdd = () => setAddingName(null);
const handleRegister = async () => {
setRegistering(true);
try {
const res = await authClient.passkey.addPasskey({
name: addingName?.trim() || undefined,
});
if (res?.error) {
toast.danger(res.error.message ?? "注册 Passkey 失败");
} else {
toast.success("Passkey 注册成功");
setAddingName(null);
await loadPasskeys();
}
} catch {
toast.danger("注册 Passkey 失败,请重试");
} finally {
setRegistering(false);
}
};
const handleDelete = async (id: string) => {
setDeletingId(id);
try {
const res = await authClient.passkey.deletePasskey({ id });
if (res?.error) {
toast.danger(res.error.message ?? "删除 Passkey 失败");
} else {
setPasskeys((prev) => prev.filter((p) => p.id !== id));
toast.success("Passkey 已删除");
}
} catch {
toast.danger("删除 Passkey 失败,请重试");
} finally {
setDeletingId(null);
}
};
const startRename = (pk: Passkey) => {
setRenamingId(pk.id);
setRenameValue(pk.name ?? "");
setTimeout(() => renameInputRef.current?.focus(), 50);
};
const cancelRename = () => {
setRenamingId(null);
setRenameValue("");
};
const handleRename = async (id: string) => {
setRenameSaving(true);
try {
const res = await (authClient.passkey as any).updatePasskey({
id,
name: renameValue.trim() || undefined,
});
if (res?.error) {
toast.danger(res.error.message ?? "重命名失败");
} else {
setPasskeys((prev) =>
prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() || null } : p)),
);
setRenamingId(null);
}
} catch {
toast.danger("重命名失败,请重试");
} finally {
setRenameSaving(false);
}
};
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>
{/* ── Profile section ─────────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
<Person className="size-5 text-accent" />
</div>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"></p>
</div>
</div>
<div className="space-y-4 px-5 py-4">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="对外显示的名称"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSaveProfile();
}}
/>
</TextField>
<div className="flex justify-end">
<Button
size="sm"
isDisabled={
savingProfile || !profileName.trim() || profileName === session?.user.name
}
onPress={handleSaveProfile}
>
{savingProfile ? <Spinner size="sm" color="current" /> : "保存"}
</Button>
</div>
</div>
</div>
{/* ── Password section ─────────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
<Lock className="size-5 text-accent" />
</div>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"></p>
</div>
</div>
<div className="space-y-4 px-5 py-4">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="password"
autoComplete="current-password"
placeholder="输入当前密码"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="password"
autoComplete="new-password"
placeholder="至少 8 位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="password"
autoComplete="new-password"
placeholder="再次输入新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleChangePassword();
}}
/>
</TextField>
{pwError && (
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{pwError}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setPwError("")} />
</Alert>
)}
{pwSuccess && (
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{pwSuccess}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setPwSuccess("")} />
</Alert>
)}
<div className="flex justify-end">
<Button
size="sm"
isDisabled={savingPw || !currentPassword || !newPassword || !confirmPassword}
onPress={handleChangePassword}
>
{savingPw ? <Spinner size="sm" color="current" /> : "确认修改"}
</Button>
</div>
</div>
</div>
{/* ── 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>
{addingName === null && (
<Button size="sm" variant="secondary" onPress={handleStartAdd}>
Passkey
</Button>
)}
</div>
{/* Inline add form */}
{addingName !== null && (
<div className="flex items-end gap-2 border-b border-border px-5 py-4">
<TextField fullWidth>
<Label className="text-sm font-medium">Passkey </Label>
<Input
autoFocus
placeholder="例如MacBook Touch ID"
value={addingName}
onChange={(e) => setAddingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleRegister();
if (e.key === "Escape") handleCancelAdd();
}}
/>
</TextField>
<Button isDisabled={registering} onPress={handleRegister}>
{registering ? <Spinner size="sm" color="current" /> : "注册"}
</Button>
<Button variant="ghost" isDisabled={registering} onPress={handleCancelAdd}>
</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="group flex items-center justify-between px-5 py-3.5">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Fingerprint className="size-4 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
{/* Name row: text+pencil or inline input */}
<div className="flex items-center gap-1.5">
{renamingId === pk.id ? (
<>
<input
ref={renameInputRef}
className="min-w-0 max-w-48 rounded-md border border-border bg-transparent px-2 py-0.5 text-sm text-foreground outline-none focus:border-accent"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleRename(pk.id);
if (e.key === "Escape") cancelRename();
}}
placeholder="输入名称"
/>
<button
type="button"
disabled={renameSaving}
className="flex size-5 items-center justify-center rounded text-accent hover:bg-accent/10 disabled:opacity-50"
onClick={() => void handleRename(pk.id)}
>
{renameSaving ? <Spinner size="sm" /> : <Check className="size-3" />}
</button>
<button
type="button"
disabled={renameSaving}
className="flex size-5 items-center justify-center rounded text-muted hover:bg-surface-tertiary disabled:opacity-50"
onClick={cancelRename}
>
<Xmark className="size-3" />
</button>
</>
) : (
<>
<p className="text-sm font-medium text-foreground">
{pk.name ?? pk.deviceType ?? "Passkey"}
</p>
<button
type="button"
className="flex size-5 items-center justify-center rounded text-muted opacity-0 transition-opacity hover:bg-surface-tertiary hover:text-foreground group-hover:opacity-100"
onClick={() => startRename(pk)}
>
<Pencil className="size-3" />
</button>
</>
)}
</div>
{/* Date row: always visible */}
<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 || renamingId === pk.id}
onPress={() => handleDelete(pk.id)}
>
{deletingId === pk.id ? <Spinner size="sm" /> : <TrashBin className="size-4" />}
</Button>
</li>
))}
</ul>
)}
</div>
</div>
);
}