diff --git a/src/App.tsx b/src/App.tsx index 7ad94c0..1aa0198 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,44 +42,55 @@ const App = () => ( - - {/* Index redirect */} - } /> - - {/* Public routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Authenticated routes */} - }> - {/* User routes */} - } /> - } /> - } /> - } /> - - {/* Organization routes */} - } /> - } /> - } /> - } /> - } /> - - - {/* Catch-all */} - } /> - + ); +// Separate component so AuthProvider can use useNavigate +import { AuthProvider } from "@/contexts/AuthContext"; + +function AppRoutes() { + return ( + + + {/* Index redirect */} + } /> + + {/* Public routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Authenticated routes */} + }> + {/* User routes */} + } /> + } /> + } /> + } /> + + {/* Organization routes */} + } /> + } /> + } /> + } /> + } /> + + + {/* Catch-all */} + } /> + + + ); +} + export default App; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..e219e94 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,86 @@ +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api, User, ApiError } from '@/lib/api'; + +interface AuthContextType { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + login: (email: string, password: string, rememberMe?: boolean) => Promise; + logout: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + + const refreshUser = useCallback(async () => { + try { + const response = await api.users.me(); + setUser(response.user); + } catch (error) { + if (error instanceof ApiError && error.code === 401) { + setUser(null); + } + // Silently fail for other errors during refresh + } + }, []); + + // Check session on mount + useEffect(() => { + const checkSession = async () => { + try { + const response = await api.users.me(); + setUser(response.user); + } catch { + setUser(null); + } finally { + setIsLoading(false); + } + }; + + checkSession(); + }, []); + + const login = useCallback(async (email: string, password: string, rememberMe = false) => { + const response = await api.auth.login(email, password, rememberMe); + setUser(response.user); + navigate('/profile'); + }, [navigate]); + + const logout = useCallback(async () => { + try { + await api.auth.logout(); + } finally { + setUser(null); + navigate('/login'); + } + }, [navigate]); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..a31f65b --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,109 @@ +// API Client for Gatehouse Backend +// Uses session-based authentication with cookies + +const API_BASE = '/api/v1'; + +interface ApiResponse { + version: string; + success: boolean; + code: number; + message: string; + data?: T; + error?: { + type: string; + details: Record; + }; +} + +export interface User { + id: string; + email: string; + full_name: string | null; + avatar_url: string | null; + is_active: boolean; + is_verified: boolean; + created_at: string; + updated_at: string; +} + +export interface Session { + id: string; + expires_at: string; +} + +export interface LoginResponse { + user: User; + session: Session; +} + +export interface ProfileResponse { + user: User; +} + +class ApiError extends Error { + code: number; + type: string; + details: Record; + + constructor(message: string, code: number, type: string, details: Record = {}) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.type = type; + this.details = details; + } +} + +async function request( + endpoint: string, + options: RequestInit = {} +): Promise { + const response = await fetch(`${API_BASE}${endpoint}`, { + ...options, + credentials: 'include', // Important: include session cookies + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + const json: ApiResponse = await response.json(); + + if (!json.success) { + throw new ApiError( + json.message || 'An error occurred', + json.code, + json.error?.type || 'UNKNOWN_ERROR', + json.error?.details || {} + ); + } + + return json.data as T; +} + +export const api = { + auth: { + login: (email: string, password: string, remember_me = false) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password, remember_me }), + }), + + logout: () => + request('/auth/logout', { + method: 'POST', + }), + }, + + users: { + me: () => request('/users/me'), + + updateMe: (data: { full_name?: string; avatar_url?: string }) => + request('/users/me', { + method: 'PATCH', + body: JSON.stringify(data), + }), + }, +}; + +export { ApiError }; diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 04ee457..f2ee4e8 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,25 +1,42 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { Mail, Lock, ArrowRight, Fingerprint } 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 { useToast } from "@/hooks/use-toast"; export default function LoginPage() { - const navigate = useNavigate(); + const { login } = useAuth(); + const { toast } = useToast(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); const [isLoading, setIsLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - // Mock login - will be replaced with actual auth - setTimeout(() => { + + try { + await login(email, password, rememberMe); + } catch (error) { + const message = error instanceof ApiError + ? error.message + : "An unexpected error occurred"; + + toast({ + variant: "destructive", + title: "Sign in failed", + description: message, + }); + } finally { setIsLoading(false); - navigate("/profile"); - }, 1000); + } }; return ( @@ -51,15 +68,7 @@ export default function LoginPage() { - - Password - - Forgot password? - - + Password + + + setRememberMe(checked === true)} + /> + + Remember me + + + + Forgot password? + + + {isLoading ? ( "Signing in..."