import { useState, useEffect, useCallback } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useAuth } from "@/contexts/AuthContext"; 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"; import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; import { OAuthProvider } from "@/lib/oauth"; type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa'; const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); /** * Complete an OIDC authorization flow after the user has authenticated. * Sends the bearer token + oidc_session_id to the backend, which generates * the auth code and returns the redirect URL for the calling application. */ async function completeOidcFlow(oidcSessionId: string, token: string): Promise { const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), }); const body = await res.json(); if (!res.ok || !body.success) { throw new Error(body.message ?? 'OIDC completion failed'); } return body.data.redirect_url as string; } export default function LoginPage() { const { login, verifyTotp, refreshUser } = useAuth(); const navigate = useNavigate(); const { toast } = useToast(); const [searchParams] = useSearchParams(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(false); const [isLoading, setIsLoading] = useState(false); const [step, setStep] = useState('credentials'); const [totpCode, setTotpCode] = useState(""); const [useBackupCode, setUseBackupCode] = useState(false); const [passkeyEmail, setPasskeyEmail] = useState(""); const [mfaToken, setMfaToken] = useState(null); // OIDC bridge: if oidc_session_id is in the URL, we're acting as the // login UI for an OIDC authorization flow (e.g. SecuIRD → Gatehouse). // After successful login, call /oidc/complete and redirect to the client app. const oidcSessionId = searchParams.get('oidc_session_id'); const oidcError = searchParams.get('error'); const finishOidcFlow = useCallback(async (token: string) => { if (!oidcSessionId) return false; try { const redirectUrl = await completeOidcFlow(oidcSessionId, token); window.location.href = redirectUrl; return true; } catch (err) { toast({ variant: "destructive", title: "Authorization failed", description: err instanceof Error ? err.message : "Could not complete OIDC authorization", }); return false; } }, [oidcSessionId, toast]); // Check for MFA step from OAuth callback useEffect(() => { if (searchParams.get('step') === 'mfa') { const storedMfaToken = sessionStorage.getItem('mfa_token'); const mfaFlow = sessionStorage.getItem('mfa_flow'); if (storedMfaToken && mfaFlow === 'external_auth') { setMfaToken(storedMfaToken); setStep('mfa'); } else { // No valid MFA token, redirect to credentials toast({ variant: "destructive", title: "Error", description: "MFA verification session expired. Please try signing in again.", }); navigate('/login', { replace: true }); } } }, [searchParams, navigate, toast]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); try { const result = await login(email, password, rememberMe); if (result.requiresWebAuthn) { setStep('webauthn'); } else if (result.requiresTotp) { setStep('totp'); setTotpCode(""); } else if (result.requiresMfaEnrollment) { // MFA enrollment required - will be handled by ProtectedLayout // Navigation happens in AuthContext } else if (oidcSessionId) { // OIDC bridge: send token back to the Gatehouse backend to complete the flow const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } // If no TOTP, WebAuthn, or MFA enrollment required, navigation happens in AuthContext } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] Login failed:", error); } const message = error instanceof ApiError ? error.message : import.meta.env.DEV && error instanceof Error ? error.message : "An unexpected error occurred"; toast({ variant: "destructive", title: "Sign in failed", description: message, }); } finally { setIsLoading(false); } }; const handleMfaSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (totpCode.length < 6 && !useBackupCode) { toast({ variant: "destructive", title: "Invalid code", description: "Please enter your complete verification code.", }); return; } setIsLoading(true); try { if (mfaToken) { // Use MFA token verification for OAuth callback flow const response = await api.totp.verifyWithMfaToken(totpCode, mfaToken, useBackupCode); // Store token and update user if (response.token) { tokenManager.setToken(response.token, response.expires_at ?? null); } // Clear MFA session data sessionStorage.removeItem('mfa_token'); sessionStorage.removeItem('mfa_flow'); // OIDC bridge: finish the flow if this is an OIDC login if (oidcSessionId && response.token) { await finishOidcFlow(response.token); } else { await refreshUser(); navigate('/profile'); } } else { // Fallback to regular TOTP verification await verifyTotp(totpCode, useBackupCode); if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } } } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] MFA verification failed:", error); } const message = error instanceof ApiError ? error.message : import.meta.env.DEV && error instanceof Error ? error.message : "Invalid verification code"; toast({ variant: "destructive", title: "Verification failed", description: message, }); setTotpCode(""); } finally { setIsLoading(false); } }; const handleTotpSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (totpCode.length < 6) { toast({ variant: "destructive", title: "Invalid code", description: "Please enter your complete verification code.", }); return; } setIsLoading(true); try { await verifyTotp(totpCode, useBackupCode); // OIDC bridge: finish the flow if this is an OIDC login if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } // Otherwise navigation happens in AuthContext } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] TOTP verification failed:", error); } const message = error instanceof ApiError ? error.message : import.meta.env.DEV && error instanceof Error ? error.message : "Invalid verification code"; toast({ variant: "destructive", title: "Verification failed", description: message, }); setTotpCode(""); } finally { setIsLoading(false); } }; 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 credentials form or passkey-email step, use it 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(); if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } else { 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); } }; // Handle WebAuthn verification specifically for the WebAuthn step (after login response) const handleWebAuthnVerify = async () => { // Use the email from the credentials form if (!email) { toast({ variant: "destructive", title: "Error", description: "Email is required. Please go back and try again.", }); handleBackToCredentials(); return; } setIsLoading(true); try { // Step 1: Get login options from server const options = await api.webauthn.beginLogin(email) 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); // OIDC bridge or normal navigation if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } else { 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] WebAuthn verification failed:", error); } let message = "Failed to verify 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. Please try again or use your authenticator app."; 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: "Verification 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(""); }; /** * Initiate OAuth login flow for external provider. * * The backend /authorize endpoint builds the Google auth URL (with the * backend callback as redirect_uri) and returns it. We then redirect the * browser to Google. After the user authenticates, Google calls the backend * callback, the backend exchanges the code for a session token, and * redirects the browser to /oauth/callback?token=... on the frontend. */ const handleOAuthLogin = async (provider: OAuthProvider) => { setIsLoading(true); try { // The redirect_uri Google will call is the *backend* callback. // The backend then redirects to the frontend /oauth/callback with the token. const backendCallbackUri = `${import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'}/auth/external/${provider}/callback`; // Ask backend for the Google authorization URL // If we're in an OIDC bridge flow, pass oidc_session_id so it survives the round-trip const response = await api.externalAuth.initiateLogin(provider, { redirect_uri: backendCallbackUri, flow: 'login', ...(oidcSessionId ? { oidc_session_id: oidcSessionId } : {}), }); // Redirect browser to provider window.location.href = response.authorization_url; } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] OAuth login failed:", error); } let message = `Failed to initiate ${provider} sign in`; if (error instanceof ApiError) { message = error.message; } else if (error instanceof Error) { message = error.message; } toast({ variant: "destructive", title: "Sign in failed", description: message, }); } finally { setIsLoading(false); } }; // Auto-submit when OTP is complete const handleOtpChange = (value: string) => { setTotpCode(value); if (value.length === 6 && !useBackupCode) { setTimeout(() => { const form = document.getElementById('totp-form') as HTMLFormElement; if (form) form.requestSubmit(); }, 100); } }; // MFA enrollment step - shows when user needs to configure MFA if (step === 'mfa-enrollment') { const [showTotpEnrollment, setShowTotpEnrollment] = useState(false); const [showPasskeyEnrollment, setShowPasskeyEnrollment] = useState(false); return (

MFA Enrollment Required

Your account requires multi-factor authentication to access full features.

Configure MFA Set up at least one authentication method to continue

After configuring MFA, you'll be redirected to your profile.

{ setShowTotpEnrollment(false); navigate('/profile'); }} /> { setShowPasskeyEnrollment(false); navigate('/profile'); }} />
); } // 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 />
); } // MFA verification step (after OAuth callback) if (step === 'mfa') { return (

Two-factor authentication

Enter the 6-digit code from your authenticator app to complete sign in

{useBackupCode ? (
setTotpCode(e.target.value.toUpperCase())} className="text-center font-mono tracking-widest" maxLength={16} autoFocus />
) : (
)}
); } // TOTP verification step if (step === 'totp') { return (

Two-factor authentication

Enter the 6-digit code from your authenticator app

{useBackupCode ? (
setTotpCode(e.target.value.toUpperCase())} className="text-center font-mono tracking-widest" maxLength={16} autoFocus />
) : (
)}
); } // WebAuthn verification step - shows when user has WebAuthn enrolled if (step === 'webauthn') { return (

Passkey verification

Use your passkey to complete sign in

or
); } // Credentials step (default) return (

{oidcSessionId ? "Sign in to continue" : "Welcome back"}

{oidcSessionId ? "An application is requesting access to your account" : "Sign in to your account to continue"}

{oidcError && (

{oidcError}

)}
setEmail(e.target.value)} className="pl-10" required />
setPassword(e.target.value)} className="pl-10" required />
setRememberMe(checked === true)} />
Forgot password?
or continue with
{/* Alternative login methods */}

Don't have an account?{" "} Create one

); }