This commit is contained in:
gpt-engineer-app[bot]
2026-01-14 15:32:30 +00:00
parent 49e10218a4
commit f9d66f9625
5 changed files with 715 additions and 60 deletions
+47 -6
View File
@@ -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<WizardStep>("name");
const [passkeyName, setPasskeyName] = useState("");
const [error, setError] = useState<string | null>(null);
const [credentialId, setCredentialId] = useState<string | null>(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));
// Step 1: Get registration options from server
const options = await api.webauthn.beginRegistration() as unknown as WebAuthnRegistrationOptions;
// Simulate success
// 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) {
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);
};
+113
View File
@@ -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<WebAuthnStatusResponse>('/auth/webauthn/status'),
// List all passkeys for current user
listCredentials: () =>
request<WebAuthnCredentialsResponse>('/auth/webauthn/credentials'),
// Begin passkey registration (returns raw WebAuthn options)
beginRegistration: async (): Promise<Record<string, unknown>> => {
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<string, unknown>, 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<Record<string, unknown>> => {
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<string, unknown>): Promise<WebAuthnLoginCompleteResponse> => {
const response = await request<WebAuthnLoginCompleteResponse>('/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 };
+190
View File
@@ -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<boolean> {
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<PublicKeyCredential> {
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<string, unknown> {
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<PublicKeyCredential> {
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<string, unknown> {
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,
},
};
}
+165 -8
View File
@@ -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<LoginStep>('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 (
<div className="auth-card">
<div className="text-center mb-8">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Fingerprint className="w-6 h-6 text-primary" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Sign in with passkey
</h1>
<p className="text-muted-foreground mt-2">
Enter your email to continue with passkey authentication
</p>
</div>
<form onSubmit={handlePasskeyEmailSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="passkey-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="passkey-email"
type="email"
placeholder="you@example.com"
value={passkeyEmail}
onChange={(e) => setPasskeyEmail(e.target.value)}
className="pl-10"
required
autoFocus
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading || !passkeyEmail}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Authenticating...
</>
) : (
<>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</form>
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={handleBackToCredentials}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to sign in
</Button>
</div>
);
}
// TOTP verification step
if (step === 'totp') {
return (
@@ -268,7 +416,10 @@ export default function LoginPage() {
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
"Signing in..."
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Signing in...
</>
) : (
<>
Sign in
@@ -287,7 +438,13 @@ export default function LoginPage() {
{/* Alternative login methods */}
<div className="space-y-3">
<Button variant="outline" className="w-full" type="button">
<Button
variant="outline"
className="w-full"
type="button"
onClick={handlePasskeyLogin}
disabled={isLoading}
>
<Fingerprint className="w-4 h-4 mr-2" />
Sign in with Passkey
</Button>
+186 -32
View File
@@ -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);
@@ -30,11 +40,27 @@ export default function SecurityPage() {
const [totpStatus, setTotpStatus] = useState<TotpStatusResponse | null>(null);
const [isTotpStatusLoading, setIsTotpStatusLoading] = useState(true);
// Passkey state
const [passkeys, setPasskeys] = useState<PasskeyCredential[]>([]);
const [isPasskeysLoading, setIsPasskeysLoading] = useState(true);
const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(null);
const [editingPasskeyName, setEditingPasskeyName] = useState("");
const [deletingPasskey, setDeletingPasskey] = useState<PasskeyCredential | null>(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 (
<div className="page-container">
<div className="page-header">
@@ -168,7 +253,7 @@ export default function SecurityPage() {
<Lock className="w-4 h-4" />
Password
</CardTitle>
<CardDescription>Last changed {security.passwordLastChanged}</CardDescription>
<CardDescription>Manage your account password</CardDescription>
</div>
<Button
variant="outline"
@@ -248,7 +333,7 @@ export default function SecurityPage() {
<CardTitle className="text-base flex items-center gap-2">
<Smartphone className="w-4 h-4" />
Authenticator App (TOTP)
{security.policyRequirements.totpRequired && (
{policyRequirements.totpRequired && (
<Badge variant="secondary" className="ml-2 text-xs">Required</Badge>
)}
</CardTitle>
@@ -301,7 +386,7 @@ export default function SecurityPage() {
<CardTitle className="text-base flex items-center gap-2">
<Fingerprint className="w-4 h-4" />
Passkeys
{security.policyRequirements.passkeysRequired && (
{policyRequirements.passkeysRequired && (
<Badge variant="secondary" className="ml-2 text-xs">Required</Badge>
)}
</CardTitle>
@@ -316,27 +401,73 @@ export default function SecurityPage() {
</div>
</CardHeader>
<CardContent className="border-t pt-4">
{isPasskeysLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Fingerprint className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p className="text-sm">No passkeys registered</p>
<p className="text-xs mt-1">Add a passkey to enable passwordless sign-in</p>
</div>
) : (
<div className="space-y-3">
{security.passkeys.map((passkey) => (
{passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center flex-shrink-0">
<Fingerprint className="w-4 h-4 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{passkey.name}</p>
<p className="text-xs text-muted-foreground">Last used: {passkey.lastUsed}</p>
{editingPasskeyId === passkey.id ? (
<Input
value={editingPasskeyName}
onChange={(e) => 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
/>
) : (
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">{passkey.name}</p>
<p className="text-xs text-muted-foreground">
Last used: {formatLastUsed(passkey.last_used_at)}
</p>
</div>
)}
</div>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
Remove
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setEditingPasskeyId(passkey.id);
setEditingPasskeyName(passkey.name);
}}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeletingPasskey(passkey)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
@@ -344,9 +475,9 @@ export default function SecurityPage() {
<AddPasskeyWizard
open={showAddPasskey}
onOpenChange={setShowAddPasskey}
onSuccess={(passkey) => {
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 */}
<AlertDialog open={!!deletingPasskey} onOpenChange={() => setDeletingPasskey(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove passkey?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove "{deletingPasskey?.name}"? You will no longer be able to use this passkey to sign in.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeletePasskey}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}