From 57429f94148eb935de73a28251e16e0be9b33d10 Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 26 Jul 2025 18:37:47 +0200 Subject: [PATCH] feat: implement authentication flow with login, registration, and OAuth support --- src/App.tsx | 42 +++++- src/components/auth/LoginForm.tsx | 99 +++++++++++++ src/components/auth/OAuthButtons.tsx | 120 +++++++++++++++ src/components/auth/RegisterForm.tsx | 144 ++++++++++++++++++ src/contexts/AuthContext.tsx | 74 ++++++++++ src/lib/api.ts | 209 +++++++++++++++++++++++++++ src/pages/AuthCallbackPage.tsx | 102 +++++++++++++ src/pages/DashboardPage.tsx | 38 +++++ src/pages/LoginPage.tsx | 24 +++ src/pages/RegisterPage.tsx | 24 +++ src/types/auth.ts | 49 +++++++ 11 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 src/components/auth/LoginForm.tsx create mode 100644 src/components/auth/OAuthButtons.tsx create mode 100644 src/components/auth/RegisterForm.tsx create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/lib/api.ts create mode 100644 src/pages/AuthCallbackPage.tsx create mode 100644 src/pages/DashboardPage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/RegisterPage.tsx create mode 100644 src/types/auth.ts diff --git a/src/App.tsx b/src/App.tsx index 9304030..2932da7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,49 @@ +import { Routes, Route, Navigate } from 'react-router' import { ThemeProvider } from './components/ThemeProvider' +import { AuthProvider, useAuth } from './contexts/AuthContext' +import { LoginPage } from './pages/LoginPage' +import { RegisterPage } from './pages/RegisterPage' +import { AuthCallbackPage } from './pages/AuthCallbackPage' +import { DashboardPage } from './pages/DashboardPage' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + + if (loading) { + return
Loading...
+ } + + if (!user) { + return + } + + return <>{children} +} + + +function AppRoutes() { + const { user } = useAuth() + + return ( + + : } /> + : } /> + } /> + + + + } /> + + ) +} function App() { return ( -
App
+ + +
) } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..2b5cc7a --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react' +import { useAuth } from '@/contexts/AuthContext' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { OAuthButtons } from './OAuthButtons' +import { ApiError } from '@/lib/api' + +export function LoginForm() { + const { login } = useAuth() + const [formData, setFormData] = useState({ + email: '', + password: '', + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + try { + await login(formData) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('An unexpected error occurred') + } + } finally { + setLoading(false) + } + } + + const handleChange = (e: React.ChangeEvent) => { + setFormData(prev => ({ + ...prev, + [e.target.name]: e.target.value, + })) + } + + return ( + + + Sign in + + Enter your email and password to sign in to your account + + + +
+
+ + +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/auth/OAuthButtons.tsx b/src/components/auth/OAuthButtons.tsx new file mode 100644 index 0000000..cadab5e --- /dev/null +++ b/src/components/auth/OAuthButtons.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { apiService } from '@/lib/api' + +export function OAuthButtons() { + const [providers, setProviders] = useState([]) + const [loading, setLoading] = useState(null) + + useEffect(() => { + const fetchProviders = async () => { + try { + const response = await apiService.getOAuthProviders() + setProviders(response.providers) + } catch (error) { + console.error('Failed to fetch OAuth providers:', error) + } + } + + fetchProviders() + }, []) + + const handleOAuthLogin = async (provider: string) => { + setLoading(provider) + try { + const response = await apiService.getOAuthUrl(provider) + + // Store state in sessionStorage for verification + sessionStorage.setItem('oauth_state', response.state) + + // Redirect to OAuth provider + window.location.href = response.authorization_url + } catch (error) { + console.error(`${provider} OAuth failed:`, error) + setLoading(null) + } + } + + const getProviderIcon = (provider: string) => { + switch (provider) { + case 'google': + return ( + + + + + + + ) + case 'github': + return ( + + + + ) + default: + return null + } + } + + const getProviderName = (provider: string) => { + return provider.charAt(0).toUpperCase() + provider.slice(1) + } + + if (providers.length === 0) { + return null + } + + return ( +
+
+
+ +
+
+ + Or continue with + +
+
+ +
+ {providers.map((provider) => ( + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..bb966bc --- /dev/null +++ b/src/components/auth/RegisterForm.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { useAuth } from '@/contexts/AuthContext' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { OAuthButtons } from './OAuthButtons' +import { ApiError } from '@/lib/api' + +export function RegisterForm() { + const { register } = useAuth() + const [formData, setFormData] = useState({ + email: '', + password: '', + confirmPassword: '', + name: '', + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match') + setLoading(false) + return + } + + if (formData.password.length < 8) { + setError('Password must be at least 8 characters long') + setLoading(false) + return + } + + try { + await register({ + email: formData.email, + password: formData.password, + name: formData.name, + }) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('An unexpected error occurred') + } + } finally { + setLoading(false) + } + } + + const handleChange = (e: React.ChangeEvent) => { + setFormData(prev => ({ + ...prev, + [e.target.name]: e.target.value, + })) + } + + return ( + + + Create account + + Enter your information to create your account + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..a3e425e --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,74 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { apiService } from '@/lib/api' +import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth' + +const AuthContext = createContext(null) + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const initAuth = async () => { + try { + // Try to get user info using cookies + const user = await apiService.getMe() + setUser(user) + } catch { + // User is not authenticated - this is normal for logged out users + } + setLoading(false) + } + + initAuth() + }, []) + + const login = async (credentials: LoginRequest) => { + try { + const response = await apiService.login(credentials) + setUser(response.user) + } catch (error) { + console.error('Login failed:', error) + throw error + } + } + + const register = async (data: RegisterRequest) => { + try { + const response = await apiService.register(data) + setUser(response.user) + } catch (error) { + console.error('Registration failed:', error) + throw error + } + } + + const logout = async () => { + await apiService.logout() + setUser(null) + } + + const value: AuthContextType = { + user, + token: user ? 'cookie-based' : null, + login, + register, + logout, + loading, + setUser, + } + + return {children} +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..0520991 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,209 @@ +import type { LoginRequest, RegisterRequest } from '@/types/auth' + +const API_BASE_URL = 'http://localhost:8000' + +class ApiError extends Error { + public status: number + public response?: unknown + + constructor(message: string, status: number, response?: unknown) { + super(message) + this.name = 'ApiError' + this.status = status + this.response = response + } +} + +class ApiService { + private refreshPromise: Promise | null = null + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${API_BASE_URL}${endpoint}` + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + } + + const config: RequestInit = { + ...options, + headers, + credentials: 'include', // Always include cookies + } + + try { + const response = await fetch(url, config) + + if (!response.ok) { + if (response.status === 401) { + try { + await this.refreshToken() + const retryResponse = await fetch(url, config) + + if (!retryResponse.ok) { + throw new ApiError( + 'Request failed after token refresh', + retryResponse.status, + await retryResponse.json().catch(() => null) + ) + } + + return await retryResponse.json() + } catch (refreshError) { + // Only redirect if we're not already on the login page to prevent infinite loops + const currentPath = window.location.pathname + if (currentPath !== '/login' && currentPath !== '/register') { + window.location.href = '/login' + } + throw refreshError + } + } + + const errorData = await response.json().catch(() => null) + throw new ApiError( + errorData?.detail || 'Request failed', + response.status, + errorData + ) + } + + return await response.json() + } catch (error) { + if (error instanceof ApiError) { + throw error + } + throw new ApiError('Network error', 0) + } + } + + private async refreshToken(): Promise { + if (this.refreshPromise) { + await this.refreshPromise + return + } + + this.refreshPromise = this.performTokenRefresh() + + try { + await this.refreshPromise + } finally { + this.refreshPromise = null + } + } + + private async performTokenRefresh(): Promise { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Send cookies with refresh token + }) + + if (!response.ok) { + throw new ApiError('Token refresh failed', response.status) + } + + // Token is automatically set in cookies by the backend + // No need to handle it manually + } + + async login(credentials: LoginRequest): Promise<{ user: User }> { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + credentials: 'include', // Important for cookies + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new ApiError( + errorData?.detail || 'Login failed', + response.status, + errorData + ) + } + + const user = await response.json() + + return { user } + } + + async register(userData: RegisterRequest): Promise<{ user: User }> { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData), + credentials: 'include', // Important for cookies + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new ApiError( + errorData?.detail || 'Registration failed', + response.status, + errorData + ) + } + + const user = await response.json() + + return { user } + } + + async getMe(): Promise { + return this.request('/api/v1/auth/me') + } + + async logout() { + try { + await fetch(`${API_BASE_URL}/api/v1/auth/logout`, { + method: 'POST', + credentials: 'include', + }) + } catch (error) { + console.error('Logout request failed:', error) + } + } + + async getOAuthUrl(provider: string): Promise<{ authorization_url: string; state: string }> { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/${provider}/authorize`, { + method: 'GET', + credentials: 'include', + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new ApiError( + errorData?.detail || 'Failed to get OAuth URL', + response.status, + errorData + ) + } + + return await response.json() + } + + async getOAuthProviders(): Promise<{ providers: string[] }> { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/providers`, { + method: 'GET', + }) + + if (!response.ok) { + throw new ApiError('Failed to get OAuth providers', response.status) + } + + return await response.json() + } + +} + +export const apiService = new ApiService() +export { ApiError } \ No newline at end of file diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx new file mode 100644 index 0000000..06bae9b --- /dev/null +++ b/src/pages/AuthCallbackPage.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' +import { useAuth } from '@/contexts/AuthContext' +import { apiService } from '@/lib/api' + +export function AuthCallbackPage() { + const navigate = useNavigate() + const { setUser } = useAuth() + const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing') + const [error, setError] = useState('') + + useEffect(() => { + const handleOAuthCallback = async () => { + try { + // Get the code from URL parameters + const urlParams = new URLSearchParams(window.location.search) + const code = urlParams.get('code') + + if (!code) { + throw new Error('No authorization code received') + } + + console.log('Exchanging OAuth code for tokens...') + + // Exchange the temporary code for proper auth cookies + const response = await fetch('http://localhost:8000/api/v1/auth/exchange-oauth-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + credentials: 'include', // Important for setting cookies + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.detail || 'Token exchange failed') + } + + const result = await response.json() + console.log('Token exchange successful:', result) + + // Now get the user info + const user = await apiService.getMe() + console.log('User info retrieved:', user) + + // Update auth context + if (setUser) setUser(user) + + setStatus('success') + + // Redirect to dashboard after a short delay + setTimeout(() => { + navigate('/') + }, 1000) + + } catch (error) { + console.error('OAuth callback failed:', error) + setError(error instanceof Error ? error.message : 'Authentication failed') + setStatus('error') + + // Redirect to login after error + setTimeout(() => { + navigate('/login') + }, 3000) + } + } + + handleOAuthCallback() + }, [navigate, setUser]) + + return ( +
+
+ {status === 'processing' && ( +
+
+

Completing sign in...

+

Please wait while we set up your account.

+
+ )} + + {status === 'success' && ( +
+
+

Sign in successful!

+

Redirecting to dashboard...

+
+ )} + + {status === 'error' && ( +
+
+

Sign in failed

+

{error}

+

Redirecting to login page...

+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..eff225d --- /dev/null +++ b/src/pages/DashboardPage.tsx @@ -0,0 +1,38 @@ +import { useAuth } from '../contexts/AuthContext' +import { ModeToggle } from '../components/ModeToggle' + +export function DashboardPage() { + const { user, logout } = useAuth() + + return ( +
+ + +
+
+
+

Dashboard content coming soon...

+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4b1fa35 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,24 @@ +import { Link } from 'react-router' +import { LoginForm } from '@/components/auth/LoginForm' + +export function LoginPage() { + return ( +
+
+ + +
+

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/RegisterPage.tsx b/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..6bb4970 --- /dev/null +++ b/src/pages/RegisterPage.tsx @@ -0,0 +1,24 @@ +import { Link } from 'react-router' +import { RegisterForm } from '@/components/auth/RegisterForm' + +export function RegisterPage() { + return ( +
+
+ + +
+

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..37cd99e --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,49 @@ +export interface User { + id: number + email: string + name: string + picture?: string + role: string + credits: number + is_active: boolean + plan: { + id: number + name: string + max_credits: number + features: string[] + } + created_at: string + updated_at: string +} + +export interface TokenResponse { + access_token: string + token_type: string + expires_in: number +} + +export interface AuthResponse { + user: User + token: TokenResponse +} + +export interface LoginRequest { + email: string + password: string +} + +export interface RegisterRequest { + email: string + password: string + name: string +} + +export interface AuthContextType { + user: User | null + token: string | null + login: (credentials: LoginRequest) => Promise + register: (data: RegisterRequest) => Promise + logout: () => Promise + loading: boolean + setUser?: (user: User | null) => void +} \ No newline at end of file