Files
openmonetis/components/ajustes/passkeys-form.tsx
2026-03-02 01:33:05 +00:00

419 lines
11 KiB
TypeScript

"use client";
import {
RiAddLine,
RiAlertLine,
RiDeleteBinLine,
RiFingerprintLine,
RiLoader4Line,
RiPencilLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useCallback, useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client";
interface Passkey {
id: string;
name: string | null;
deviceType: string;
createdAt: Date | null;
}
export function PasskeysForm() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [passkeySupported, setPasskeySupported] = useState(false);
// Add passkey
const [isAddOpen, setIsAddOpen] = useState(false);
const [addName, setAddName] = useState("");
const [isAdding, setIsAdding] = useState(false);
// Rename passkey
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
// Delete passkey
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const isMutating = isAdding || isRenaming || isDeleting;
const fetchPasskeys = useCallback(
async (options?: { showLoader?: boolean }) => {
const showLoader = options?.showLoader ?? true;
if (showLoader) setIsLoading(true);
setError(null);
try {
const { data, error: fetchError } =
await authClient.passkey.listUserPasskeys();
if (fetchError) {
setError(fetchError.message || "Erro ao carregar passkeys.");
return;
}
setPasskeys(
(data ?? []).map((p) => ({
id: p.id,
name: p.name,
deviceType: p.deviceType,
createdAt: p.createdAt ? new Date(p.createdAt) : null,
})),
);
} catch {
setError("Erro ao carregar passkeys.");
} finally {
if (showLoader) setIsLoading(false);
}
},
[],
);
useEffect(() => {
if (typeof window === "undefined") return;
setPasskeySupported(typeof PublicKeyCredential !== "undefined");
fetchPasskeys();
}, [fetchPasskeys]);
const handleAdd = async () => {
if (!passkeySupported) {
setError("Passkeys não são suportadas neste navegador/dispositivo.");
return;
}
setIsAdding(true);
setError(null);
try {
const { error: addError } = await authClient.passkey.addPasskey({
name: addName.trim() || undefined,
});
if (addError) {
setError(addError.message || "Erro ao registrar passkey.");
return;
}
setAddName("");
setIsAddOpen(false);
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao registrar passkey.");
} finally {
setIsAdding(false);
}
};
const handleRename = async (id: string) => {
if (!editName.trim()) return;
setIsRenaming(true);
setError(null);
try {
const { error: renameError } = await authClient.passkey.updatePasskey({
id,
name: editName.trim(),
});
if (renameError) {
setError(renameError.message || "Erro ao renomear passkey.");
return;
}
setEditingId(null);
setEditName("");
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao renomear passkey.");
} finally {
setIsRenaming(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
setError(null);
try {
const { error: deleteError } = await authClient.passkey.deletePasskey({
id: deleteId,
});
if (deleteError) {
setError(deleteError.message || "Erro ao remover passkey.");
return;
}
setDeleteId(null);
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao remover passkey.");
} finally {
setIsDeleting(false);
}
};
const startEditing = (passkey: Passkey) => {
setEditingId(passkey.id);
setEditName(passkey.name || "");
};
const cancelEditing = () => {
setEditingId(null);
setEditName("");
};
const deviceTypeLabel = (type: string) => {
switch (type) {
case "singleDevice":
return "Dispositivo único";
case "multiDevice":
return "Multi-dispositivo";
default:
return type;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Suas passkeys</h3>
<p className="text-sm text-muted-foreground">
Gerencie suas passkeys para login sem senha.
</p>
</div>
<Dialog
open={isAddOpen}
onOpenChange={(open) => {
if (!open) {
setAddName("");
setError(null);
}
setIsAddOpen(open);
}}
>
<DialogTrigger asChild>
<Button size="sm" disabled={isMutating || !passkeySupported}>
<RiAddLine className="h-4 w-4 mr-1" />
Adicionar
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar Passkey</DialogTitle>
<DialogDescription>
um nome para identificar esta passkey (opcional). Em seguida,
seu navegador solicitará a confirmação biométrica.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkeyName">Nome (opcional)</Label>
<Input
id="passkeyName"
placeholder="Ex: MacBook Pro, iPhone..."
value={addName}
onChange={(e) => setAddName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAdd();
}
}}
disabled={isAdding}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddOpen(false)}
disabled={isAdding}
>
Cancelar
</Button>
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? (
<>
<RiLoader4Line className="h-4 w-4 animate-spin mr-1" />
Registrando...
</>
) : (
"Registrar"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{error && !isAddOpen && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{!passkeySupported && !isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RiAlertLine className="h-4 w-4 shrink-0" />
Este navegador/dispositivo não suporta passkeys.
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<RiLoader4Line className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="flex items-center gap-3 py-4 text-muted-foreground">
<RiFingerprintLine className="h-5 w-5" />
<p className="text-sm">
Nenhuma passkey cadastrada. Adicione uma para login sem senha.
</p>
</div>
) : (
<div className="divide-y py-2">
{passkeys.map((pk) => (
<div
key={pk.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<RiFingerprintLine className="h-4 w-4" />
</div>
<div className="min-w-0">
{editingId === pk.id ? (
<div className="flex items-center gap-2">
<Input
className="h-7 text-sm w-40"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(pk.id);
if (e.key === "Escape") cancelEditing();
}}
autoFocus
disabled={isRenaming}
/>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => handleRename(pk.id)}
disabled={isRenaming || !editName.trim()}
>
{isRenaming ? (
<RiLoader4Line className="h-3 w-3 animate-spin" />
) : (
"Salvar"
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={cancelEditing}
disabled={isRenaming}
>
Cancelar
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-bold truncate">
{pk.name || "Passkey sem nome"}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEditing(pk)}
disabled={isMutating}
>
<RiPencilLine className="h-3 w-3" />
</Button>
</div>
<p className="text-xs text-muted-foreground py-1">
{deviceTypeLabel(pk.deviceType)}
{pk.createdAt
? ` · Criada ${formatDistanceToNow(pk.createdAt, {
addSuffix: true,
locale: ptBR,
})}`
: " · Data de criação indisponível"}
</p>
</>
)}
</div>
</div>
{editingId !== pk.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteId(pk.id)}
disabled={isMutating}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remover passkey?</AlertDialogTitle>
<AlertDialogDescription>
Esta passkey não poderá mais ser usada para login. Esta ação não
pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isDeleting ? "Removendo..." : "Remover"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}