S-02: Extract extract_credential_raw_id() helper in services/passkey.py — replaces 2 inline rawId parsing blocks in passkeys.py S-03: Add PasskeyLoginResponse type, use in useAuth passkeyLoginMutation S-04: Add Cancel button to disable-passwordless dialog W-03: Invalidate auth queries on disable ceremony error/cancel Perf-2: Session cap uses ID-only query + bulk UPDATE instead of loading full ORM objects and flipping booleans individually Perf-3: Remove passkey_count from /auth/status hot path (polled every 15s). Use EXISTS for has_passkeys boolean. Count derived from passkeys list query in PasskeySection (passkeys.length). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import type { PasskeyCredential } from '@/types';
|
|
|
|
function formatRelativeTime(dateStr: string | null): string {
|
|
if (!dateStr) return 'Never';
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
if (diffDays < 30) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return '';
|
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
|
day: 'numeric', month: 'short', year: 'numeric',
|
|
});
|
|
}
|
|
|
|
function detectDeviceName(): string {
|
|
const ua = navigator.userAgent;
|
|
let browser = 'Browser';
|
|
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
|
|
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
|
|
else if (ua.includes('Firefox')) browser = 'Firefox';
|
|
else if (ua.includes('Edg')) browser = 'Edge';
|
|
|
|
let os = '';
|
|
if (ua.includes('Mac')) os = 'macOS';
|
|
else if (ua.includes('Windows')) os = 'Windows';
|
|
else if (ua.includes('Linux')) os = 'Linux';
|
|
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
|
else if (ua.includes('Android')) os = 'Android';
|
|
|
|
return os ? `${os} \u2014 ${browser}` : browser;
|
|
}
|
|
|
|
interface DeleteConfirmProps {
|
|
credential: PasskeyCredential;
|
|
onDelete: (id: number, password: string) => void;
|
|
isDeleting: boolean;
|
|
}
|
|
|
|
function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) {
|
|
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
|
const [password, setPassword] = useState('');
|
|
|
|
const { confirming, handleClick } = useConfirmAction(() => {
|
|
setShowPasswordDialog(true);
|
|
});
|
|
|
|
const handleSubmitDelete = () => {
|
|
onDelete(credential.id, password);
|
|
setPassword('');
|
|
setShowPasswordDialog(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-red-400"
|
|
onClick={handleClick}
|
|
disabled={isDeleting}
|
|
aria-label={`Remove passkey ${credential.name}`}
|
|
>
|
|
{confirming ? (
|
|
<span className="text-xs font-medium text-red-400">Sure?</span>
|
|
) : (
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
|
|
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Remove passkey</DialogTitle>
|
|
<DialogDescription>
|
|
Enter your password to remove "{credential.name}".
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="delete-passkey-password">Password</Label>
|
|
<Input
|
|
id="delete-passkey-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleSubmitDelete}
|
|
disabled={!password || isDeleting}
|
|
>
|
|
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function PasskeySection() {
|
|
const queryClient = useQueryClient();
|
|
const { passwordlessEnabled, allowPasswordless } = useAuth();
|
|
|
|
// Registration state
|
|
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
|
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
|
|
const [registerPassword, setRegisterPassword] = useState('');
|
|
const [passkeyName, setPasskeyName] = useState('');
|
|
const [pendingCredential, setPendingCredential] = useState<{
|
|
credential: string;
|
|
challenge_token: string;
|
|
} | null>(null);
|
|
|
|
// Passwordless enable state
|
|
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
|
|
const [enablePassword, setEnablePassword] = useState('');
|
|
|
|
// Passwordless disable state
|
|
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
|
|
|
const passkeysQuery = useQuery({
|
|
queryKey: ['passkeys'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<PasskeyCredential[]>('/auth/passkeys');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const registerMutation = useMutation({
|
|
mutationFn: async ({ password }: { password: string }) => {
|
|
const { startRegistration } = await import('@simplewebauthn/browser');
|
|
|
|
// Step 1: Get registration options (requires password V-02)
|
|
const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password });
|
|
|
|
// Step 2: Browser WebAuthn ceremony
|
|
setCeremonyState('waiting');
|
|
const credential = await startRegistration(beginResp.options);
|
|
|
|
return {
|
|
credential: JSON.stringify(credential),
|
|
challenge_token: beginResp.challenge_token,
|
|
};
|
|
},
|
|
onSuccess: (data) => {
|
|
setPendingCredential(data);
|
|
setPasskeyName(detectDeviceName());
|
|
setCeremonyState('naming');
|
|
},
|
|
onError: (error: unknown) => {
|
|
if (error instanceof Error && error.name === 'NotAllowedError') {
|
|
toast.info('Passkey setup cancelled');
|
|
} else if (error instanceof Error && error.name === 'AbortError') {
|
|
toast.info('Cancelled');
|
|
} else {
|
|
toast.error(getErrorMessage(error, 'Failed to create passkey'));
|
|
}
|
|
setRegisterDialogOpen(false);
|
|
resetRegisterState();
|
|
},
|
|
});
|
|
|
|
const completeMutation = useMutation({
|
|
mutationFn: async ({ credential, challenge_token, name }: {
|
|
credential: string; challenge_token: string; name: string;
|
|
}) => {
|
|
const { data } = await api.post('/auth/passkeys/register/complete', {
|
|
credential, challenge_token, name,
|
|
});
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Passkey registered');
|
|
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
setRegisterDialogOpen(false);
|
|
resetRegisterState();
|
|
},
|
|
onError: (error: unknown) => {
|
|
toast.error(getErrorMessage(error, 'Failed to save passkey'));
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async ({ id, password }: { id: number; password: string }) => {
|
|
await api.delete(`/auth/passkeys/${id}`, { data: { password } });
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Passkey removed');
|
|
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
},
|
|
onError: (error: unknown) => {
|
|
toast.error(getErrorMessage(error, 'Failed to remove passkey'));
|
|
},
|
|
});
|
|
|
|
const enablePasswordlessMutation = useMutation({
|
|
mutationFn: async ({ password }: { password: string }) => {
|
|
const { data } = await api.put('/auth/passkeys/passwordless/enable', { password });
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Passwordless login enabled');
|
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
setEnableDialogOpen(false);
|
|
setEnablePassword('');
|
|
},
|
|
onError: (error: unknown) => {
|
|
toast.error(getErrorMessage(error, 'Failed to enable passwordless login'));
|
|
},
|
|
});
|
|
|
|
const disablePasswordlessMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const { startAuthentication } = await import('@simplewebauthn/browser');
|
|
const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {});
|
|
const credential = await startAuthentication(beginResp.options);
|
|
const { data } = await api.put('/auth/passkeys/passwordless/disable', {
|
|
credential: JSON.stringify(credential),
|
|
challenge_token: beginResp.challenge_token,
|
|
});
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Passwordless login disabled');
|
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
setDisableDialogOpen(false);
|
|
},
|
|
onError: (error: unknown) => {
|
|
if (error instanceof Error && error.name === 'NotAllowedError') {
|
|
toast.error('Passkey not recognized');
|
|
} else if (error instanceof Error && error.name === 'AbortError') {
|
|
toast.info('Cancelled');
|
|
} else {
|
|
toast.error(getErrorMessage(error, 'Failed to disable passwordless login'));
|
|
}
|
|
setDisableDialogOpen(false);
|
|
// W-03: Invalidate to resync switch state after failed/cancelled ceremony
|
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
},
|
|
});
|
|
|
|
const resetRegisterState = useCallback(() => {
|
|
setCeremonyState('password');
|
|
setRegisterPassword('');
|
|
setPasskeyName('');
|
|
setPendingCredential(null);
|
|
}, []);
|
|
|
|
const handleStartRegister = () => {
|
|
resetRegisterState();
|
|
setRegisterDialogOpen(true);
|
|
};
|
|
|
|
const handlePasswordSubmit = () => {
|
|
if (!registerPassword) return;
|
|
registerMutation.mutate({ password: registerPassword });
|
|
};
|
|
|
|
const handleSaveName = () => {
|
|
if (!pendingCredential || !passkeyName.trim()) return;
|
|
completeMutation.mutate({
|
|
...pendingCredential,
|
|
name: passkeyName.trim(),
|
|
});
|
|
};
|
|
|
|
const handleDelete = (id: number, password: string) => {
|
|
deleteMutation.mutate({ id, password });
|
|
};
|
|
|
|
const passkeys = passkeysQuery.data ?? [];
|
|
const hasPasskeys = passkeys.length > 0;
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-accent/10">
|
|
<Fingerprint className="h-4 w-4 text-accent" />
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<Label>Passkeys</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Sign in with your fingerprint, face, or security key
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{hasPasskeys && (
|
|
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-semibold text-green-400">
|
|
{passkeys.length} registered
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{hasPasskeys && (
|
|
<ul className="space-y-1" aria-live="polite">
|
|
{passkeys.map((pk) => (
|
|
<li
|
|
key={pk.id}
|
|
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
|
>
|
|
<div className="p-1.5 rounded-md bg-accent/10">
|
|
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium truncate">{pk.name}</span>
|
|
{pk.backed_up && (
|
|
<Cloud className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Synced" />
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
|
|
</span>
|
|
</div>
|
|
<PasskeyDeleteButton
|
|
credential={pk}
|
|
onDelete={handleDelete}
|
|
isDeleting={deleteMutation.isPending && (deleteMutation.variables as { id: number } | undefined)?.id === pk.id}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2"
|
|
onClick={handleStartRegister}
|
|
>
|
|
<Fingerprint className="h-4 w-4" />
|
|
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
|
|
</Button>
|
|
|
|
{/* Passwordless login section — hidden when admin hasn't enabled the feature */}
|
|
{(allowPasswordless || passwordlessEnabled) && <Separator />}
|
|
|
|
{(allowPasswordless || passwordlessEnabled) && (
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Fingerprint className="h-4 w-4 text-muted-foreground" />
|
|
<Label className="text-sm font-medium">Passwordless Login</Label>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Skip the password prompt and unlock the app using a passkey only.
|
|
</p>
|
|
{passkeys.length < 2 && !passwordlessEnabled && (
|
|
<p className="text-xs text-amber-400">
|
|
Requires at least 2 registered passkeys as a fallback.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Switch
|
|
checked={passwordlessEnabled}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setEnablePassword('');
|
|
setEnableDialogOpen(true);
|
|
} else {
|
|
setDisableDialogOpen(true);
|
|
disablePasswordlessMutation.mutate();
|
|
}
|
|
}}
|
|
disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending}
|
|
aria-label="Toggle passwordless login"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Enable passwordless dialog */}
|
|
<Dialog open={enableDialogOpen} onOpenChange={(open) => {
|
|
if (!open) { setEnableDialogOpen(false); setEnablePassword(''); }
|
|
}}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="p-2 rounded-lg bg-accent/10">
|
|
<Fingerprint className="h-5 w-5 text-accent" />
|
|
</div>
|
|
<DialogTitle>Enable Passwordless Login</DialogTitle>
|
|
</div>
|
|
<DialogDescription>
|
|
Confirm your password to enable passkey-only login.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="enable-passwordless-password">Password</Label>
|
|
<Input
|
|
id="enable-passwordless-password"
|
|
type="password"
|
|
value={enablePassword}
|
|
onChange={(e) => setEnablePassword(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && enablePassword) {
|
|
enablePasswordlessMutation.mutate({ password: enablePassword });
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => { setEnableDialogOpen(false); setEnablePassword(''); }}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => enablePasswordlessMutation.mutate({ password: enablePassword })}
|
|
disabled={!enablePassword || enablePasswordlessMutation.isPending}
|
|
>
|
|
{enablePasswordlessMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Disable passwordless dialog */}
|
|
<Dialog open={disableDialogOpen} onOpenChange={(open) => {
|
|
if (!open && !disablePasswordlessMutation.isPending) {
|
|
setDisableDialogOpen(false);
|
|
}
|
|
}}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="p-2 rounded-lg bg-orange-500/10">
|
|
<ShieldOff className="h-5 w-5 text-orange-400" />
|
|
</div>
|
|
<DialogTitle>Disable Passwordless Login</DialogTitle>
|
|
</div>
|
|
<DialogDescription>
|
|
Verify with your passkey to disable passwordless login.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col items-center gap-4 py-4">
|
|
{disablePasswordlessMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Follow your browser's prompt to verify your passkey
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Ready to verify your passkey
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDisableDialogOpen(false)}
|
|
disabled={disablePasswordlessMutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Registration ceremony dialog */}
|
|
<Dialog
|
|
open={registerDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setRegisterDialogOpen(false);
|
|
resetRegisterState();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="p-2 rounded-lg bg-accent/10">
|
|
<Fingerprint className="h-5 w-5 text-accent" />
|
|
</div>
|
|
<DialogTitle>
|
|
{ceremonyState === 'password' && 'Add a passkey'}
|
|
{ceremonyState === 'waiting' && 'Creating passkey'}
|
|
{ceremonyState === 'naming' && 'Name your passkey'}
|
|
</DialogTitle>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{ceremonyState === 'password' && (
|
|
<>
|
|
<DialogDescription>
|
|
Enter your password to add a passkey to your account.
|
|
</DialogDescription>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="register-passkey-password">Password</Label>
|
|
<Input
|
|
id="register-passkey-password"
|
|
type="password"
|
|
value={registerPassword}
|
|
onChange={(e) => setRegisterPassword(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handlePasswordSubmit}
|
|
disabled={!registerPassword || registerMutation.isPending}
|
|
>
|
|
{registerMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
|
|
{ceremonyState === 'waiting' && (
|
|
<div className="flex flex-col items-center gap-4 py-6">
|
|
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Follow your browser's prompt to create a passkey
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{ceremonyState === 'naming' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="passkey-name">Name</Label>
|
|
<Input
|
|
id="passkey-name"
|
|
value={passkeyName}
|
|
onChange={(e) => setPasskeyName(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }}
|
|
maxLength={100}
|
|
autoFocus
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Give this passkey a name to help you identify it later.
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={handleSaveName}
|
|
disabled={!passkeyName.trim() || completeMutation.isPending}
|
|
>
|
|
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|