refactor(passkeys): use TanStack mutations for passkey CRUD operations
This commit is contained in:
parent
d77ab281f7
commit
c5ea75cecc
1 changed files with 78 additions and 84 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue