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
+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>
+196 -42
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);
@@ -29,12 +39,28 @@ export default function SecurityPage() {
const [totpEnabled, setTotpEnabled] = useState(false);
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">
<div className="space-y-3">
{security.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">
<Fingerprint className="w-4 h-4 text-primary" />
{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">
{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 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>
{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>
<div>
<p className="text-sm font-medium text-foreground">{passkey.name}</p>
<p className="text-xs text-muted-foreground">Last used: {passkey.lastUsed}</p>
<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>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
Remove
</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>
);
}