Files
zerobyte/app/client/modules/settings/components/passkeys-section.tsx
Nico 98338e80c3 Add passkey authentication support (#845)
* feat(auth): add passkey authentication support

* fix: implement AI review feedback

* fix: use non-unique index for passkey_credentialID_idx in migration

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

* chore: restore lockfile from main and add @better-auth/passkey

* chore: fix conflicts

* refactor(passkey-login): simplify passkey autofill event

* refactor(settings-passkeys): ux improvements

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-21 21:18:46 +02:00

309 lines
9.1 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type PasskeyEntry = {
id: string;
name?: string | null;
createdAt: Date | string;
deviceType?: string;
};
export function PasskeysSection() {
const { formatDateTime } = useTimeFormat();
const { data: passkeys, isPending } = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (error) throw error;
return data;
},
});
const [deletePasskeyOpen, setDeletePasskeyOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null);
const addPasskeyMutation = useMutation({
mutationFn: async (name: string | undefined) => {
const { error } = await authClient.passkey.addPasskey({ name });
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey added");
setAddDialogOpen(false);
setNewPasskeyName("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to add passkey", { description: error.message });
},
});
const renamePasskeyMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id, name },
});
if (error) throw error;
},
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.passkey.deletePasskey({ id });
if (error) throw error;
},
onMutate: () => {
setDeletePasskeyOpen(false);
},
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.ChangeEvent) => {
e.preventDefault();
const name = newPasskeyName.trim() || undefined;
addPasskeyMutation.mutate(name);
};
const handleRename = (e: React.ChangeEvent) => {
e.preventDefault();
if (!renameTarget) return;
const name = renameValue.trim();
if (!name) {
toast.error("Name is required");
return;
}
renamePasskeyMutation.mutate({ id: renameTarget.id, name });
};
const handleDelete = () => {
if (!deleteTarget) return;
deletePasskeyMutation.mutate(deleteTarget.id);
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5" />
Passkeys
</CardTitle>
<CardDescription className="mt-1.5">
Sign in faster and more securely with passkeys stored on your device or password manager. You can
add more than one.
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<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 cannot be reused across sites.
</p>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add passkey
</Button>
</div>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
<p className={cn("text-sm text-muted-foreground", { hidden: passkeys && passkeys.length > 0 })}>
No passkeys yet. Add one to enable passwordless sign-in.
</p>
<ul
className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
hidden: passkeys?.length === 0,
})}
>
{passkeys?.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p>
<p className="text-xs text-muted-foreground">
Added {formatDateTime(new Date(p.createdAt))}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
aria-label={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setRenameTarget(p);
setRenameValue(p.name ?? "");
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
aria-label={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setDeleteTarget(p);
setDeletePasskeyOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</CardContent>
<Dialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) setNewPasskeyName("");
}}
>
<DialogContent>
<form onSubmit={handleAddPasskey}>
<DialogHeader>
<DialogTitle>Add a passkey</DialogTitle>
<DialogDescription>
Give this passkey a name so you can recognize it later (e.g. "MacBook Touch ID").
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-name">Name (optional)</Label>
<Input
id="passkey-name"
value={newPasskeyName}
onChange={(e) => setNewPasskeyName(e.target.value)}
placeholder="My Laptop"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={addPasskeyMutation.isPending}>
<Fingerprint className="h-4 w-4 mr-2" />
Add passkey
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renameTarget)}
onOpenChange={(open) => {
if (!open) {
setRenameTarget(null);
setRenameValue("");
}
}}
>
<DialogContent>
<form onSubmit={handleRename}>
<DialogHeader>
<DialogTitle>Rename passkey</DialogTitle>
<DialogDescription>Choose a name to recognize this passkey.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-rename">Name</Label>
<Input
id="passkey-rename"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button type="submit" loading={renamePasskeyMutation.isPending}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deletePasskeyOpen} onOpenChange={setDeletePasskeyOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle>
<AlertDialogDescription>
This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You
won't be able to use it to sign in anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePasskeyMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={deletePasskeyMutation.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}