refactor(settings-passkeys): ux improvements

This commit is contained in:
Nicolas Meienberger 2026-05-21 16:53:41 +02:00
parent 5362151f9f
commit 97c70ea65a
No known key found for this signature in database
2 changed files with 73 additions and 71 deletions

View file

@ -51,14 +51,15 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
useEffect(() => { useEffect(() => {
if (
!PublicKeyCredential.isConditionalMediationAvailable ||
!PublicKeyCredential.isConditionalMediationAvailable()
) {
return;
}
const autoSignIn = async () => { const autoSignIn = async () => {
if (
typeof PublicKeyCredential === "undefined" ||
!PublicKeyCredential.isConditionalMediationAvailable ||
!(await PublicKeyCredential.isConditionalMediationAvailable())
) {
return;
}
await authClient.signIn.passkey({ await authClient.signIn.passkey({
autoFill: true, autoFill: true,
fetchOptions: { fetchOptions: {

View file

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } 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";
@ -27,6 +27,7 @@ import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger"; import { logger } from "~/client/lib/logger";
import { useTimeFormat } from "~/client/lib/datetime"; import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type PasskeyEntry = { type PasskeyEntry = {
id: string; id: string;
@ -37,8 +38,16 @@ type PasskeyEntry = {
export function PasskeysSection() { export function PasskeysSection() {
const { formatDateTime } = useTimeFormat(); const { formatDateTime } = useTimeFormat();
const { data: passkeys, isPending } = authClient.useListPasskeys(); 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 [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState(""); const [newPasskeyName, setNewPasskeyName] = useState("");
@ -84,12 +93,12 @@ export function PasskeysSection() {
const deletePasskeyMutation = useMutation({ const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const { error } = await authClient.$fetch("/passkey/delete-passkey", { const { error } = await authClient.passkey.deletePasskey({ id });
method: "POST",
body: { id },
});
if (error) throw error; if (error) throw error;
}, },
onMutate: () => {
setDeletePasskeyOpen(false);
},
onSuccess: () => { onSuccess: () => {
toast.success("Passkey deleted"); toast.success("Passkey deleted");
setDeleteTarget(null); setDeleteTarget(null);
@ -100,13 +109,13 @@ export function PasskeysSection() {
}, },
}); });
const handleAddPasskey = (e: React.FormEvent) => { const handleAddPasskey = (e: React.ChangeEvent) => {
e.preventDefault(); e.preventDefault();
const name = newPasskeyName.trim() || undefined; const name = newPasskeyName.trim() || undefined;
addPasskeyMutation.mutate(name); addPasskeyMutation.mutate(name);
}; };
const handleRename = (e: React.FormEvent) => { const handleRename = (e: React.ChangeEvent) => {
e.preventDefault(); e.preventDefault();
if (!renameTarget) return; if (!renameTarget) return;
const name = renameValue.trim(); const name = renameValue.trim();
@ -122,8 +131,6 @@ export function PasskeysSection() {
deletePasskeyMutation.mutate(deleteTarget.id); deletePasskeyMutation.mutate(deleteTarget.id);
}; };
const list = (passkeys ?? []) as PasskeyEntry[];
return ( return (
<> <>
<div className="border-t border-border/50 bg-card-header p-6"> <div className="border-t border-border/50 bg-card-header p-6">
@ -148,51 +155,52 @@ export function PasskeysSection() {
</Button> </Button>
</div> </div>
{isPending ? ( <p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
<p className="text-sm text-muted-foreground">Loading passkeys...</p> <p className={cn("text-sm text-muted-foreground", { hidden: passkeys && passkeys.length > 0 })}>
) : list.length === 0 ? ( No passkeys yet. Add one to enable passwordless sign-in.
<p className="text-sm text-muted-foreground"> </p>
No passkeys yet. Add one to enable passwordless sign-in. <ul
</p> className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
) : ( hidden: passkeys?.length === 0,
<ul className="divide-y divide-border/50 rounded-md border border-border/50"> })}
{list.map((p) => ( >
<li key={p.id} className="flex items-center justify-between gap-4 p-3"> {passkeys?.map((p) => (
<div className="min-w-0 flex-1"> <li key={p.id} className="flex items-center justify-between gap-4 p-3">
<p className="text-sm font-medium truncate"> <div className="min-w-0 flex-1">
{p.name?.trim() || "Unnamed passkey"} <p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p>
</p> <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> Added {formatDateTime(new Date(p.createdAt))}
Added {formatDateTime(new Date(p.createdAt))} </p>
</p> </div>
</div> <div className="flex gap-2">
<div className="flex gap-2"> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" aria-label={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
aria-label={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`} title={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`} onClick={() => {
onClick={() => { setRenameTarget(p);
setRenameTarget(p); setRenameValue(p.name ?? "");
setRenameValue(p.name ?? ""); }}
}} >
> <Pencil className="h-4 w-4" />
<Pencil className="h-4 w-4" /> </Button>
</Button> <Button
<Button variant="destructive"
variant="destructive" size="sm"
size="sm" aria-label={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
aria-label={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`} title={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`} onClick={() => {
onClick={() => setDeleteTarget(p)} setDeleteTarget(p);
> setDeletePasskeyOpen(true);
<Trash2 className="h-4 w-4" /> }}
</Button> >
</div> <Trash2 className="h-4 w-4" />
</li> </Button>
))} </div>
</ul> </li>
)} ))}
</ul>
</CardContent> </CardContent>
<Dialog <Dialog
@ -218,7 +226,6 @@ export function PasskeysSection() {
value={newPasskeyName} value={newPasskeyName}
onChange={(e) => setNewPasskeyName(e.target.value)} onChange={(e) => setNewPasskeyName(e.target.value)}
placeholder="My Laptop" placeholder="My Laptop"
autoFocus
/> />
</div> </div>
</div> </div>
@ -257,7 +264,6 @@ export function PasskeysSection() {
id="passkey-rename" id="passkey-rename"
value={renameValue} value={renameValue}
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
autoFocus
required required
/> />
</div> </div>
@ -274,12 +280,7 @@ export function PasskeysSection() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog <AlertDialog open={deletePasskeyOpen} onOpenChange={setDeletePasskeyOpen}>
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle> <AlertDialogTitle>Delete passkey?</AlertDialogTitle>
@ -293,7 +294,7 @@ export function PasskeysSection() {
<AlertDialogAction <AlertDialogAction
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
void handleDelete(); handleDelete();
}} }}
disabled={deletePasskeyMutation.isPending} disabled={deletePasskeyMutation.isPending}
> >