From b77f2630a16d21223820693e66d04fadc2cc640a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:21:55 +0000 Subject: [PATCH] Changes --- src/contexts/AuthContext.tsx | 26 +++++- src/lib/api.ts | 28 ++++-- src/pages/auth/LoginPage.tsx | 165 ++++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 14 deletions(-) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index f7f7752..4611838 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -2,11 +2,16 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode import { useNavigate } from 'react-router-dom'; import { api, User, ApiError, tokenManager } from '@/lib/api'; +interface LoginResult { + requiresTotp: boolean; +} + interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; - login: (email: string, password: string, rememberMe?: boolean) => Promise; + login: (email: string, password: string, rememberMe?: boolean) => Promise; + verifyTotp: (code: string, isBackupCode?: boolean) => Promise; logout: () => Promise; refreshUser: () => Promise; } @@ -53,8 +58,24 @@ export function AuthProvider({ children }: { children: ReactNode }) { checkAuth(); }, []); - const login = useCallback(async (email: string, password: string, rememberMe = false) => { + const login = useCallback(async (email: string, password: string, rememberMe = false): Promise => { const response = await api.auth.login(email, password, rememberMe); + + // If TOTP is required, don't set user yet - wait for TOTP verification + if (response.requires_totp) { + return { requiresTotp: true }; + } + + // Login complete, set user and navigate + if (response.user) { + setUser(response.user); + navigate('/profile'); + } + return { requiresTotp: false }; + }, [navigate]); + + const verifyTotp = useCallback(async (code: string, isBackupCode = false) => { + const response = await api.totp.verify(code, isBackupCode); setUser(response.user); navigate('/profile'); }, [navigate]); @@ -75,6 +96,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isLoading, isAuthenticated: !!user, login, + verifyTotp, logout, refreshUser, }} diff --git a/src/lib/api.ts b/src/lib/api.ts index 6c775a9..58bb0e5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -45,9 +45,9 @@ export interface OrganizationsResponse { } export interface LoginResponse { - user: User; - token: string; - expires_at: string; + user?: User; + token?: string; + expires_at?: string; requires_totp?: boolean; } @@ -211,10 +211,13 @@ export const api = { const response = await request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password, remember_me }), + credentials: 'include', // Required for TOTP session tracking }, false); // Login doesn't require auth - // Store token on successful login - tokenManager.setToken(response.token, response.expires_at); + // Only store token if login is complete (no TOTP required) + if (response.token && response.expires_at && !response.requires_totp) { + tokenManager.setToken(response.token, response.expires_at); + } return response; }, @@ -270,11 +273,20 @@ export const api = { }, true, { clearTokenOn401: false }), // Verify TOTP code during login (no auth required - uses session state) - verify: (code: string, isBackupCode = false) => - request('/auth/totp/verify', { + verify: async (code: string, isBackupCode = false): Promise => { + const response = await request('/auth/totp/verify', { method: 'POST', body: JSON.stringify({ code, is_backup_code: isBackupCode }), - }, false), + credentials: 'include', // Required for TOTP session tracking + }, false); + + // Store token after successful TOTP verification + if (response.token && response.expires_at) { + tokenManager.setToken(response.token, response.expires_at); + } + + return response; + }, // Get TOTP status status: () => diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 504690f..be8a124 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Link } from "react-router-dom"; -import { Mail, Lock, ArrowRight, Fingerprint } from "lucide-react"; +import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -9,23 +9,37 @@ import { Checkbox } from "@/components/ui/checkbox"; import { useAuth } from "@/contexts/AuthContext"; import { ApiError } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; + +type LoginStep = 'credentials' | 'totp'; export default function LoginPage() { - const { login } = useAuth(); + const { login, verifyTotp } = useAuth(); const { toast } = useToast(); 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 handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); try { - await login(email, password, rememberMe); + const result = await login(email, password, rememberMe); + if (result.requiresTotp) { + setStep('totp'); + setTotpCode(""); + } + // If no TOTP required, navigation happens in AuthContext } catch (error) { - // Log to console in dev mode for easier debugging if (import.meta.env.DEV) { console.error("[Gatehouse] Login failed:", error); } @@ -46,6 +60,149 @@ export default function LoginPage() { } }; + 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); + // 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 handleBackToCredentials = () => { + setStep('credentials'); + setTotpCode(""); + setUseBackupCode(false); + }; + + // 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(); + }, 100); + } + }; + + // 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 + /> +
+ ) : ( +
+ + + + + + + + + + +
+ )} + + +
+ +
+ + + +
+
+ ); + } + + // Credentials step (default) return (