feat: implement authentication pages and API integration for login and registration

This commit is contained in:
JSC
2025-07-26 14:38:38 +02:00
parent 12cb39503b
commit 652a318d16
6 changed files with 505 additions and 3 deletions

View File

@@ -1,9 +1,33 @@
import { Routes, Route } from 'react-router'
import { useSearchParams } from 'react-router'
import { useEffect } from 'react'
import { ThemeProvider } from './components/ThemeProvider'
import { LoginPage } from './pages/auth/LoginPage'
import { RegisterPage } from './pages/auth/RegisterPage'
function Dashboard() {
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
if (searchParams.get('auth') === 'success') {
// Clear the auth parameter from URL
setSearchParams({})
// You could show a success toast here
console.log('OAuth authentication successful!')
}
}, [searchParams, setSearchParams])
return <div>Dashboard - Coming Soon</div>
}
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<div>App</div>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<Dashboard />} />
</Routes>
</ThemeProvider>
)
}

View File

@@ -2,7 +2,7 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"

View File

@@ -1,5 +1,5 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()

125
src/lib/api.ts Normal file
View File

@@ -0,0 +1,125 @@
const API_BASE_URL = 'http://localhost:8000/api/v1'
export interface LoginRequest {
email: string
password: string
}
export interface RegisterRequest {
email: string
password: string
name: string
}
export interface UserResponse {
id: number
email: string
name: string
picture?: string
role: string
credits: number
is_active: boolean
plan: Record<string, unknown>
created_at: string
updated_at: string
}
class ApiError extends Error {
public status: number
public details?: unknown
constructor(
message: string,
status: number,
details?: unknown
) {
super(message)
this.name = 'ApiError'
this.status = status
this.details = details
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new ApiError(
errorData.detail || `HTTP error! status: ${response.status}`,
response.status,
errorData
)
}
return response.json()
}
export interface OAuthAuthorizationResponse {
authorization_url: string
state: string
}
export interface OAuthProvidersResponse {
providers: string[]
}
export const authApi = {
async login(data: LoginRequest): Promise<UserResponse> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
})
return handleResponse<UserResponse>(response)
},
async register(data: RegisterRequest): Promise<UserResponse> {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
})
return handleResponse<UserResponse>(response)
},
async getCurrentUser(): Promise<UserResponse> {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
credentials: 'include',
})
return handleResponse<UserResponse>(response)
},
async logout(): Promise<void> {
const response = await fetch(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
await response.json()
},
async refreshToken(): Promise<void> {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
await response.json()
},
}
export const oauthApi = {
async getProviders(): Promise<OAuthProvidersResponse> {
const response = await fetch(`${API_BASE_URL}/oauth/providers`)
return handleResponse<OAuthProvidersResponse>(response)
},
async getAuthorizationUrl(provider: string): Promise<OAuthAuthorizationResponse> {
const response = await fetch(`${API_BASE_URL}/oauth/${provider}/authorize`)
return handleResponse<OAuthAuthorizationResponse>(response)
},
}
export { ApiError }

View File

@@ -0,0 +1,193 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Github } from 'lucide-react'
import { authApi, oauthApi, type LoginRequest } from '@/lib/api'
export function LoginPage() {
const [formData, setFormData] = useState<LoginRequest>({
email: '',
password: '',
})
const [isLoading, setIsLoading] = useState(false)
const [isOAuthLoading, setIsOAuthLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [providers, setProviders] = useState<string[]>([])
useEffect(() => {
oauthApi.getProviders()
.then(response => setProviders(response.providers))
.catch(error => console.error('Failed to load OAuth providers:', error))
}, [])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
await authApi.login(formData)
// Redirect to dashboard or home page
window.location.href = '/'
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'Login failed')
} finally {
setIsLoading(false)
}
}
const handleOAuthLogin = async (provider: string) => {
setIsOAuthLoading(provider)
setError(null)
try {
// Get authorization URL from backend and redirect
const authResponse = await oauthApi.getAuthorizationUrl(provider)
window.location.href = authResponse.authorization_url
} catch (error: unknown) {
setError(error instanceof Error ? error.message : `${provider} login failed`)
setIsOAuthLoading(null)
}
}
const getProviderIcon = (provider: string) => {
switch (provider.toLowerCase()) {
case 'github':
return <Github className="w-4 h-4" />
case 'google':
return (
<svg className="w-4 h-4" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
)
default:
return null
}
}
const getProviderName = (provider: string) => {
return provider.charAt(0).toUpperCase() + provider.slice(1)
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
Sign in
</CardTitle>
<CardDescription className="text-center">
Enter your email and password to access your account
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
{error}
</div>
)}
{/* OAuth Buttons */}
{providers.length > 0 && (
<div className="space-y-3 mb-6">
{providers.map((provider) => (
<Button
key={provider}
type="button"
variant="outline"
className="w-full"
onClick={() => handleOAuthLogin(provider)}
disabled={isOAuthLoading === provider || isLoading}
>
{isOAuthLoading === provider ? (
'Connecting...'
) : (
<>
{getProviderIcon(provider)}
<span className="ml-2">Continue with {getProviderName(provider)}</span>
</>
)}
</Button>
))}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
</div>
)}
{/* Email/Password Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
required
disabled={isLoading || isOAuthLoading !== null}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password"
value={formData.password}
onChange={handleInputChange}
required
disabled={isLoading || isOAuthLoading !== null}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || isOAuthLoading !== null}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
<div className="mt-6 text-center text-sm">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-primary hover:underline"
>
Sign up
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,160 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi, type RegisterRequest } from '@/lib/api'
export function RegisterPage() {
const [formData, setFormData] = useState<RegisterRequest>({
email: '',
password: '',
name: '',
})
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
if (name === 'confirmPassword') {
setConfirmPassword(value)
} else {
setFormData(prev => ({
...prev,
[name]: value,
}))
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
if (formData.password !== confirmPassword) {
setError('Passwords do not match')
setIsLoading(false)
return
}
if (formData.password.length < 8) {
setError('Password must be at least 8 characters long')
setIsLoading(false)
return
}
try {
await authApi.register(formData)
// Redirect to dashboard or home page
window.location.href = '/'
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'Registration failed')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
Create account
</CardTitle>
<CardDescription className="text-center">
Enter your information to create a new account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="John Doe"
value={formData.name}
onChange={handleInputChange}
required
disabled={isLoading}
minLength={1}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password (min 8 characters)"
value={formData.password}
onChange={handleInputChange}
required
disabled={isLoading}
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={handleInputChange}
required
disabled={isLoading}
minLength={8}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</form>
<div className="mt-6 text-center text-sm">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-primary hover:underline"
>
Sign in
</Link>
</div>
</CardContent>
</Card>
</div>
)
}