feat: implement authentication pages and API integration for login and registration
This commit is contained in:
26
src/App.tsx
26
src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
125
src/lib/api.ts
Normal 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 }
|
||||
193
src/pages/auth/LoginPage.tsx
Normal file
193
src/pages/auth/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
src/pages/auth/RegisterPage.tsx
Normal file
160
src/pages/auth/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user