diff --git a/src/components/security/AddPasskeyWizard.tsx b/src/components/security/AddPasskeyWizard.tsx index 0ba6fd0..866cd0b 100644 --- a/src/components/security/AddPasskeyWizard.tsx +++ b/src/components/security/AddPasskeyWizard.tsx @@ -10,6 +10,13 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { api, ApiError } from "@/lib/api"; +import { + isWebAuthnSupported, + createRegistrationCredential, + formatRegistrationCredential, + WebAuthnRegistrationOptions, +} from "@/lib/webauthn"; interface AddPasskeyWizardProps { open: boolean; @@ -23,28 +30,61 @@ export function AddPasskeyWizard({ open, onOpenChange, onSuccess }: AddPasskeyWi const [step, setStep] = useState("name"); const [passkeyName, setPasskeyName] = useState(""); const [error, setError] = useState(null); + const [credentialId, setCredentialId] = useState(null); const handleStartRegistration = async () => { if (!passkeyName.trim()) return; - + + if (!isWebAuthnSupported()) { + setError("WebAuthn is not supported in this browser. Please use a modern browser."); + setStep("error"); + return; + } + setStep("registering"); setError(null); try { - // Simulate WebAuthn registration flow - // In production, this would call the backend to get challenge options, - // then call navigator.credentials.create() - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Simulate success + // Step 1: Get registration options from server + const options = await api.webauthn.beginRegistration() as unknown as WebAuthnRegistrationOptions; + + // Step 2: Create credential using browser WebAuthn API + const credential = await createRegistrationCredential(options); + + // Step 3: Format and send credential to server with name + const formattedCredential = formatRegistrationCredential(credential); + const result = await api.webauthn.completeRegistration(formattedCredential, passkeyName.trim()); + + setCredentialId(result.credential_id); setStep("success"); - + // Notify parent after a short delay setTimeout(() => { - onSuccess?.({ id: crypto.randomUUID(), name: passkeyName.trim() }); + onSuccess?.({ id: result.credential_id, name: passkeyName.trim() }); }, 1500); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to register passkey"); + console.error("Passkey registration failed:", err); + + if (err instanceof ApiError) { + setError(err.message); + } else if (err instanceof DOMException) { + // Handle WebAuthn-specific errors + switch (err.name) { + case "NotAllowedError": + setError("Registration was cancelled or timed out. Please try again."); + break; + case "InvalidStateError": + setError("This authenticator is already registered."); + break; + case "NotSupportedError": + setError("Your device doesn't support the required authentication method."); + break; + default: + setError(err.message || "Failed to register passkey"); + } + } else { + setError(err instanceof Error ? err.message : "Failed to register passkey"); + } setStep("error"); } }; @@ -56,6 +96,7 @@ export function AddPasskeyWizard({ open, onOpenChange, onSuccess }: AddPasskeyWi setStep("name"); setPasskeyName(""); setError(null); + setCredentialId(null); }, 200); }; diff --git a/src/lib/api.ts b/src/lib/api.ts index 58bb0e5..eb2e5d3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -74,6 +74,32 @@ export interface ProfileResponse { user: User; } +// WebAuthn types +export interface PasskeyCredential { + id: string; + name: string; + transports: string[]; + device_type: string; + created_at: string; + last_used_at: string | null; +} + +export interface WebAuthnStatusResponse { + webauthn_enabled: boolean; + credential_count: number; +} + +export interface WebAuthnCredentialsResponse { + credentials: PasskeyCredential[]; + count: number; +} + +export interface WebAuthnLoginCompleteResponse { + user: User; + token: string; + expires_at: string; +} + class ApiError extends Error { code: number; type: string; @@ -306,6 +332,93 @@ export const api = { body: JSON.stringify({ password }), }, true, { clearTokenOn401: false }), }, + + webauthn: { + // Get WebAuthn status + status: () => + request('/auth/webauthn/status'), + + // List all passkeys for current user + listCredentials: () => + request('/auth/webauthn/credentials'), + + // Begin passkey registration (returns raw WebAuthn options) + beginRegistration: async (): Promise> => { + const response = await fetch(`${config.api.baseUrl}/auth/webauthn/register/begin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenManager.getToken()}`, + }, + }); + if (!response.ok) { + const error = await response.json(); + throw new ApiError( + error.message || 'Failed to begin registration', + error.code || response.status, + error.error?.type || 'WEBAUTHN_ERROR', + error.error?.details || {} + ); + } + // Returns raw WebAuthn options (not wrapped in standard response) + return response.json(); + }, + + // Complete passkey registration + completeRegistration: (credential: Record, name?: string) => + request<{ message: string; credential_id: string }>('/auth/webauthn/register/complete', { + method: 'POST', + body: JSON.stringify({ ...credential, name }), + }), + + // Begin passkey login (returns raw WebAuthn options) + beginLogin: async (email: string): Promise> => { + const response = await fetch(`${config.api.baseUrl}/auth/webauthn/login/begin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!response.ok) { + const error = await response.json(); + throw new ApiError( + error.message || 'No passkeys found for this account', + error.code || response.status, + error.error?.type || 'WEBAUTHN_ERROR', + error.error?.details || {} + ); + } + // Returns raw WebAuthn options (not wrapped in standard response) + return response.json(); + }, + + // Complete passkey login + completeLogin: async (assertion: Record): Promise => { + const response = await request('/auth/webauthn/login/complete', { + method: 'POST', + body: JSON.stringify(assertion), + }, false); + + // Store token after successful passkey login + if (response.token && response.expires_at) { + tokenManager.setToken(response.token, response.expires_at); + } + + return response; + }, + + // Rename a passkey + renameCredential: (credentialId: string, name: string) => + request<{ message: string }>(`/auth/webauthn/credentials/${credentialId}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }), + + // Delete a passkey + deleteCredential: (credentialId: string) => + request<{ message: string }>(`/auth/webauthn/credentials/${credentialId}`, { + method: 'DELETE', + }), + }, }; export { ApiError }; diff --git a/src/lib/webauthn.ts b/src/lib/webauthn.ts new file mode 100644 index 0000000..463bee6 --- /dev/null +++ b/src/lib/webauthn.ts @@ -0,0 +1,190 @@ +// WebAuthn utility functions for passkey authentication + +// Convert Base64URL to ArrayBuffer +export function base64ToBuffer(base64: string): ArrayBuffer { + const base64Url = base64.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (base64Url.length % 4)) % 4); + const binary = atob(base64Url + padding); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +// Convert ArrayBuffer to Base64URL +export function bufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +// Check if WebAuthn is supported +export function isWebAuthnSupported(): boolean { + return !!( + navigator.credentials && + typeof navigator.credentials.create === 'function' && + typeof navigator.credentials.get === 'function' && + window.PublicKeyCredential + ); +} + +// Check if platform authenticator is available (Touch ID, Face ID, Windows Hello) +export async function isPlatformAuthenticatorAvailable(): Promise { + if (!isWebAuthnSupported()) return false; + try { + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } catch { + return false; + } +} + +// Types for WebAuthn API responses +export interface WebAuthnRegistrationOptions { + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; + challenge: string; + pubKeyCredParams: Array<{ + type: 'public-key'; + alg: number; + }>; + timeout: number; + excludeCredentials: Array<{ + id: string; + type: 'public-key'; + transports?: string[]; + }>; + authenticatorSelection: { + residentKey: string; + userVerification: string; + authenticatorAttachment?: string; + }; + attestation: string; +} + +export interface WebAuthnLoginOptions { + challenge: string; + timeout: number; + rpId: string; + allowCredentials: Array<{ + id: string; + type: 'public-key'; + transports?: string[]; + }>; + userVerification: string; +} + +export interface PasskeyCredential { + id: string; + name: string; + transports: string[]; + device_type: string; + created_at: string; + last_used_at: string | null; +} + +export interface WebAuthnStatusResponse { + webauthn_enabled: boolean; + credential_count: number; +} + +// Create registration credential from server options +export async function createRegistrationCredential( + options: WebAuthnRegistrationOptions +): Promise { + const publicKeyOptions: PublicKeyCredentialCreationOptions = { + ...options, + challenge: base64ToBuffer(options.challenge), + user: { + id: base64ToBuffer(options.user.id), + name: options.user.name, + displayName: options.user.displayName, + }, + excludeCredentials: options.excludeCredentials.map((cred) => ({ + ...cred, + id: base64ToBuffer(cred.id), + transports: cred.transports as AuthenticatorTransport[] | undefined, + })), + pubKeyCredParams: options.pubKeyCredParams, + authenticatorSelection: { + ...options.authenticatorSelection, + residentKey: options.authenticatorSelection.residentKey as ResidentKeyRequirement, + userVerification: options.authenticatorSelection.userVerification as UserVerificationRequirement, + authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment as AuthenticatorAttachment | undefined, + }, + attestation: options.attestation as AttestationConveyancePreference, + }; + + const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }); + if (!credential || !(credential instanceof PublicKeyCredential)) { + throw new Error('Failed to create credential'); + } + return credential; +} + +// Format registration credential for server +export function formatRegistrationCredential(credential: PublicKeyCredential): Record { + const response = credential.response as AuthenticatorAttestationResponse; + return { + id: credential.id, + rawId: bufferToBase64(credential.rawId), + type: credential.type, + response: { + attestationObject: bufferToBase64(response.attestationObject), + clientDataJSON: bufferToBase64(response.clientDataJSON), + }, + transports: response.getTransports?.() || [], + }; +} + +// Create login assertion from server options +export async function createLoginAssertion( + options: WebAuthnLoginOptions +): Promise { + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + challenge: base64ToBuffer(options.challenge), + timeout: options.timeout, + rpId: options.rpId, + allowCredentials: options.allowCredentials.map((cred) => ({ + ...cred, + id: base64ToBuffer(cred.id), + transports: cred.transports as AuthenticatorTransport[] | undefined, + })), + userVerification: options.userVerification as UserVerificationRequirement, + }; + + const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }); + if (!assertion || !(assertion instanceof PublicKeyCredential)) { + throw new Error('Failed to get assertion'); + } + return assertion; +} + +// Format login assertion for server +export function formatLoginAssertion(assertion: PublicKeyCredential): Record { + const response = assertion.response as AuthenticatorAssertionResponse; + return { + id: assertion.id, + rawId: bufferToBase64(assertion.rawId), + type: assertion.type, + response: { + authenticatorData: bufferToBase64(response.authenticatorData), + clientDataJSON: bufferToBase64(response.clientDataJSON), + signature: bufferToBase64(response.signature), + userHandle: response.userHandle ? bufferToBase64(response.userHandle) : null, + }, + }; +} diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index be8a124..57071d1 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,24 +1,31 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; -import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { useAuth } from "@/contexts/AuthContext"; -import { ApiError } from "@/lib/api"; +import { api, ApiError, tokenManager } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; import { InputOTP, InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import { + isWebAuthnSupported, + createLoginAssertion, + formatLoginAssertion, + WebAuthnLoginOptions, +} from "@/lib/webauthn"; -type LoginStep = 'credentials' | 'totp'; +type LoginStep = 'credentials' | 'totp' | 'passkey-email'; export default function LoginPage() { - const { login, verifyTotp } = useAuth(); + const { login, verifyTotp, refreshUser } = useAuth(); + const navigate = useNavigate(); const { toast } = useToast(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -27,6 +34,7 @@ export default function LoginPage() { const [step, setStep] = useState('credentials'); const [totpCode, setTotpCode] = useState(""); const [useBackupCode, setUseBackupCode] = useState(false); + const [passkeyEmail, setPasskeyEmail] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -99,17 +107,96 @@ export default function LoginPage() { } }; + const handlePasskeyLogin = async () => { + if (!isWebAuthnSupported()) { + toast({ + variant: "destructive", + title: "Not supported", + description: "Passkeys are not supported in this browser.", + }); + return; + } + + // If we have an email from the form, use it directly + const emailToUse = email || passkeyEmail; + + if (!emailToUse) { + setStep('passkey-email'); + return; + } + + setIsLoading(true); + + try { + // Step 1: Get login options from server + const options = await api.webauthn.beginLogin(emailToUse) as unknown as WebAuthnLoginOptions; + + // Step 2: Create assertion using browser WebAuthn API + const assertion = await createLoginAssertion(options); + + // Step 3: Complete login with server + const formattedAssertion = formatLoginAssertion(assertion); + const result = await api.webauthn.completeLogin(formattedAssertion); + + // Token is stored by completeLogin, refresh user and navigate + await refreshUser(); + navigate('/profile'); + + toast({ + title: "Welcome back", + description: `Signed in as ${result.user.email}`, + }); + } catch (error) { + if (import.meta.env.DEV) { + console.error("[Gatehouse] Passkey login failed:", error); + } + + let message = "Failed to sign in with passkey"; + + if (error instanceof ApiError) { + message = error.message; + } else if (error instanceof DOMException) { + switch (error.name) { + case "NotAllowedError": + message = "Authentication was cancelled or timed out."; + break; + case "InvalidStateError": + message = "No passkey found for this account."; + break; + default: + message = error.message || message; + } + } else if (error instanceof Error) { + message = error.message; + } + + toast({ + variant: "destructive", + title: "Passkey sign in failed", + description: message, + }); + } finally { + setIsLoading(false); + } + }; + + const handlePasskeyEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!passkeyEmail) return; + await handlePasskeyLogin(); + }; + const handleBackToCredentials = () => { setStep('credentials'); setTotpCode(""); setUseBackupCode(false); + setPasskeyEmail(""); }; // Auto-submit when OTP is complete const handleOtpChange = (value: string) => { setTotpCode(value); if (value.length === 6 && !useBackupCode) { - // Small delay to allow the UI to update before submitting setTimeout(() => { const form = document.getElementById('totp-form') as HTMLFormElement; if (form) form.requestSubmit(); @@ -117,6 +204,67 @@ export default function LoginPage() { } }; + // Passkey email entry step + if (step === 'passkey-email') { + return ( +
+
+
+ +
+

+ Sign in with passkey +

+

+ Enter your email to continue with passkey authentication +

+
+ +
+
+ +
+ + setPasskeyEmail(e.target.value)} + className="pl-10" + required + autoFocus + /> +
+
+ + +
+ + +
+ ); + } + // TOTP verification step if (step === 'totp') { return ( @@ -268,7 +416,10 @@ export default function LoginPage() { diff --git a/src/pages/user/SecurityPage.tsx b/src/pages/user/SecurityPage.tsx index c2cd73a..1f4c428 100644 --- a/src/pages/user/SecurityPage.tsx +++ b/src/pages/user/SecurityPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Lock, Fingerprint, Smartphone, Shield, Plus, CheckCircle, Loader2 } from "lucide-react"; +import { Lock, Fingerprint, Smartphone, Shield, Plus, CheckCircle, Loader2, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -9,8 +9,18 @@ import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; -import { api, ApiError, TotpStatusResponse } from "@/lib/api"; +import { api, ApiError, TotpStatusResponse, PasskeyCredential } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; export default function SecurityPage() { const [showPasswordForm, setShowPasswordForm] = useState(false); @@ -29,12 +39,28 @@ export default function SecurityPage() { const [totpEnabled, setTotpEnabled] = useState(false); const [totpStatus, setTotpStatus] = useState(null); const [isTotpStatusLoading, setIsTotpStatusLoading] = useState(true); + + // Passkey state + const [passkeys, setPasskeys] = useState([]); + const [isPasskeysLoading, setIsPasskeysLoading] = useState(true); + const [editingPasskeyId, setEditingPasskeyId] = useState(null); + const [editingPasskeyName, setEditingPasskeyName] = useState(""); + const [deletingPasskey, setDeletingPasskey] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); const { toast } = useToast(); + // Policy requirements (could come from org settings in future) + const policyRequirements = { + totpRequired: true, + passkeysRequired: false, + minPasswordLength: 12, + }; + // Fetch TOTP status on mount useEffect(() => { fetchTotpStatus(); + fetchPasskeys(); }, []); const fetchTotpStatus = async () => { @@ -45,26 +71,23 @@ export default function SecurityPage() { setTotpEnabled(status.totp_enabled); } catch (err) { console.error("Failed to fetch TOTP status:", err); - // Don't show error toast - just assume TOTP is not enabled setTotpEnabled(false); } finally { setIsTotpStatusLoading(false); } }; - // Mock security data - const security = { - passwordLastChanged: "3 months ago", - passkeysCount: 2, - passkeys: [ - { id: "1", name: "MacBook Pro Touch ID", lastUsed: "Today" }, - { id: "2", name: "iPhone Face ID", lastUsed: "Yesterday" }, - ], - policyRequirements: { - totpRequired: true, - passkeysRequired: false, - minPasswordLength: 12, - }, + const fetchPasskeys = async () => { + setIsPasskeysLoading(true); + try { + const response = await api.webauthn.listCredentials(); + setPasskeys(response.credentials); + } catch (err) { + console.error("Failed to fetch passkeys:", err); + setPasskeys([]); + } finally { + setIsPasskeysLoading(false); + } }; const resetPasswordForm = () => { @@ -77,7 +100,6 @@ export default function SecurityPage() { const handlePasswordChange = async () => { setPasswordError(null); - // Client-side validation if (!currentPassword) { setPasswordError("Current password is required"); return; @@ -134,6 +156,69 @@ export default function SecurityPage() { setShowPasswordForm(false); }; + const handleRenamePasskey = async (passkey: PasskeyCredential) => { + if (!editingPasskeyName.trim() || editingPasskeyName === passkey.name) { + setEditingPasskeyId(null); + return; + } + + try { + await api.webauthn.renameCredential(passkey.id, editingPasskeyName.trim()); + setPasskeys(passkeys.map(p => + p.id === passkey.id ? { ...p, name: editingPasskeyName.trim() } : p + )); + toast({ + title: "Passkey renamed", + description: `Passkey renamed to "${editingPasskeyName.trim()}"`, + }); + } catch (err) { + console.error("Failed to rename passkey:", err); + toast({ + variant: "destructive", + title: "Failed to rename passkey", + description: err instanceof ApiError ? err.message : "An error occurred", + }); + } finally { + setEditingPasskeyId(null); + setEditingPasskeyName(""); + } + }; + + const handleDeletePasskey = async () => { + if (!deletingPasskey) return; + + setIsDeleting(true); + try { + await api.webauthn.deleteCredential(deletingPasskey.id); + setPasskeys(passkeys.filter(p => p.id !== deletingPasskey.id)); + toast({ + title: "Passkey removed", + description: `"${deletingPasskey.name}" has been removed.`, + }); + } catch (err) { + console.error("Failed to delete passkey:", err); + toast({ + variant: "destructive", + title: "Failed to remove passkey", + description: err instanceof ApiError ? err.message : "An error occurred", + }); + } finally { + setIsDeleting(false); + setDeletingPasskey(null); + } + }; + + const formatLastUsed = (date: string | null) => { + if (!date) return "Never"; + const d = new Date(date); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays} days ago`; + return d.toLocaleDateString(); + }; + return (
@@ -168,7 +253,7 @@ export default function SecurityPage() { Password - Last changed {security.passwordLastChanged} + Manage your account password
-
- {security.passkeys.map((passkey) => ( -
-
-
- + {isPasskeysLoading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
+ +

No passkeys registered

+

Add a passkey to enable passwordless sign-in

+
+ ) : ( +
+ {passkeys.map((passkey) => ( +
+
+
+ +
+ {editingPasskeyId === passkey.id ? ( + setEditingPasskeyName(e.target.value)} + onBlur={() => handleRenamePasskey(passkey)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRenamePasskey(passkey); + if (e.key === "Escape") setEditingPasskeyId(null); + }} + className="h-8 max-w-[200px]" + autoFocus + /> + ) : ( +
+

{passkey.name}

+

+ Last used: {formatLastUsed(passkey.last_used_at)} +

+
+ )}
-
-

{passkey.name}

-

Last used: {passkey.lastUsed}

+
+ +
- -
- ))} -
+ ))} +
+ )}
@@ -344,9 +475,9 @@ export default function SecurityPage() { { - console.log("Passkey added:", passkey); + onSuccess={() => { setShowAddPasskey(false); + fetchPasskeys(); }} /> @@ -356,7 +487,7 @@ export default function SecurityPage() { onSuccess={() => { setTotpEnabled(true); setShowTotpEnrollment(false); - fetchTotpStatus(); // Refresh status after enrollment + fetchTotpStatus(); }} /> @@ -368,8 +499,31 @@ export default function SecurityPage() { setTotpStatus(null); setShowTotpRemove(false); }} - isRequired={security.policyRequirements.totpRequired} + isRequired={policyRequirements.totpRequired} /> + + {/* Delete Passkey Confirmation */} + setDeletingPasskey(null)}> + + + Remove passkey? + + Are you sure you want to remove "{deletingPasskey?.name}"? You will no longer be able to use this passkey to sign in. + + + + Cancel + + {isDeleting && } + Remove + + + +
); }