refactor(passkeys): use TanStack mutations for passkey CRUD operations

This commit is contained in:
Nico 2026-05-04 11:31:19 +02:00 committed by Nicolas Meienberger
parent d77ab281f7
commit c5ea75cecc
No known key found for this signature in database

View file

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react"; import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -36,46 +37,76 @@ type PasskeyEntry = {
export function PasskeysSection() { export function PasskeysSection() {
const { formatDateTime } = useTimeFormat(); const { formatDateTime } = useTimeFormat();
const { data: passkeys, isPending, refetch } = authClient.useListPasskeys(); const { data: passkeys, isPending } = authClient.useListPasskeys();
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState(""); const [newPasskeyName, setNewPasskeyName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null); const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null); const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleAddPasskey = async (e: React.FormEvent) => { const addPasskeyMutation = useMutation({
e.preventDefault(); mutationFn: async (name: string | undefined) => {
setIsAdding(true); const { error } = await authClient.passkey.addPasskey({ name });
try { if (error) throw error;
const { error } = await authClient.passkey.addPasskey({ },
name: newPasskeyName.trim() || undefined, onSuccess: () => {
});
if (error) {
logger.error(error);
toast.error("Failed to add passkey", { description: error.message });
return;
}
toast.success("Passkey added"); toast.success("Passkey added");
setAddDialogOpen(false); setAddDialogOpen(false);
setNewPasskeyName(""); setNewPasskeyName("");
await refetch(); },
} catch (err) { onError: (error: Error) => {
logger.error(err); logger.error(error);
toast.error("Failed to add passkey", { toast.error("Failed to add passkey", { description: error.message });
description: err instanceof Error ? err.message : "Unknown error", },
});
const renamePasskeyMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id, name },
}); });
} finally { if (error) throw error;
setIsAdding(false); },
} onSuccess: () => {
toast.success("Passkey renamed");
setRenameTarget(null);
setRenameValue("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to rename passkey", { description: error.message });
},
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await authClient.$fetch("/passkey/delete-passkey", {
method: "POST",
body: { id },
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey deleted");
setDeleteTarget(null);
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to delete passkey", { description: error.message });
},
});
const handleAddPasskey = (e: React.FormEvent) => {
e.preventDefault();
const name = newPasskeyName.trim() || undefined;
addPasskeyMutation.mutate(name);
}; };
const handleRename = async (e: React.FormEvent) => { const handleRename = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!renameTarget) return; if (!renameTarget) return;
const name = renameValue.trim(); const name = renameValue.trim();
@ -83,55 +114,12 @@ export function PasskeysSection() {
toast.error("Name is required"); toast.error("Name is required");
return; return;
} }
setIsRenaming(true); renamePasskeyMutation.mutate({ id: renameTarget.id, name });
try {
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id: renameTarget.id, name },
});
if (error) {
logger.error(error);
toast.error("Failed to rename passkey", { description: error.message });
return;
}
toast.success("Passkey renamed");
setRenameTarget(null);
setRenameValue("");
await refetch();
} catch (err) {
logger.error(err);
toast.error("Failed to rename passkey", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsRenaming(false);
}
}; };
const handleDelete = async () => { const handleDelete = () => {
if (!deleteTarget) return; if (!deleteTarget) return;
setIsDeleting(true); deletePasskeyMutation.mutate(deleteTarget.id);
try {
const { error } = await authClient.$fetch("/passkey/delete-passkey", {
method: "POST",
body: { id: deleteTarget.id },
});
if (error) {
logger.error(error);
toast.error("Failed to delete passkey", { description: error.message });
return;
}
toast.success("Passkey deleted");
setDeleteTarget(null);
await refetch();
} catch (err) {
logger.error(err);
toast.error("Failed to delete passkey", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsDeleting(false);
}
}; };
const list = (passkeys ?? []) as PasskeyEntry[]; const list = (passkeys ?? []) as PasskeyEntry[];
@ -144,15 +132,15 @@ export function PasskeysSection() {
Passkeys Passkeys
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5"> <CardDescription className="mt-1.5">
Sign in faster and more securely with passkeys stored on your device or password manager. You can add more Sign in faster and more securely with passkeys stored on your device or password manager. You can
than one. add more than one.
</CardDescription> </CardDescription>
</div> </div>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-xl"> <p className="text-xs text-muted-foreground max-w-xl">
Passkeys use your device's biometrics or screen lock instead of a password. They are phishing-resistant and Passkeys use your device's biometrics or screen lock instead of a password. They are
cannot be reused across sites. phishing-resistant and cannot be reused across sites.
</p> </p>
<Button onClick={() => setAddDialogOpen(true)}> <Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
@ -163,14 +151,20 @@ export function PasskeysSection() {
{isPending ? ( {isPending ? (
<p className="text-sm text-muted-foreground">Loading passkeys...</p> <p className="text-sm text-muted-foreground">Loading passkeys...</p>
) : list.length === 0 ? ( ) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">No passkeys yet. Add one to enable passwordless sign-in.</p> <p className="text-sm text-muted-foreground">
No passkeys yet. Add one to enable passwordless sign-in.
</p>
) : ( ) : (
<ul className="divide-y divide-border/50 rounded-md border border-border/50"> <ul className="divide-y divide-border/50 rounded-md border border-border/50">
{list.map((p) => ( {list.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-4 p-3"> <li key={p.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p> <p className="text-sm font-medium truncate">
<p className="text-xs text-muted-foreground">Added {formatDateTime(new Date(p.createdAt))}</p> {p.name?.trim() || "Unnamed passkey"}
</p>
<p className="text-xs text-muted-foreground">
Added {formatDateTime(new Date(p.createdAt))}
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -232,7 +226,7 @@ export function PasskeysSection() {
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}> <Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={isAdding}> <Button type="submit" loading={addPasskeyMutation.isPending}>
<Fingerprint className="h-4 w-4 mr-2" /> <Fingerprint className="h-4 w-4 mr-2" />
Add passkey Add passkey
</Button> </Button>
@ -272,7 +266,7 @@ export function PasskeysSection() {
<Button type="button" variant="outline" onClick={() => setRenameTarget(null)}> <Button type="button" variant="outline" onClick={() => setRenameTarget(null)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={isRenaming}> <Button type="submit" loading={renamePasskeyMutation.isPending}>
Save Save
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -290,18 +284,18 @@ export function PasskeysSection() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle> <AlertDialogTitle>Delete passkey?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You won't be able to This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You
use it to sign in anymore. won't be able to use it to sign in anymore.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={deletePasskeyMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
void handleDelete(); void handleDelete();
}} }}
disabled={isDeleting} disabled={deletePasskeyMutation.isPending}
> >
Delete Delete
</AlertDialogAction> </AlertDialogAction>