Compare commits
2 Commits
57429f9414
...
6018a5c8c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6018a5c8c5 | ||
|
|
6ce83c8317 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,6 +12,11 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -1,69 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default tseslint.config([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default tseslint.config([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage'
|
|||||||
import { RegisterPage } from './pages/RegisterPage'
|
import { RegisterPage } from './pages/RegisterPage'
|
||||||
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
||||||
import { DashboardPage } from './pages/DashboardPage'
|
import { DashboardPage } from './pages/DashboardPage'
|
||||||
|
import { Toaster } from './components/ui/sonner'
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -20,7 +21,6 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ function App() {
|
|||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
<Toaster />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { apiService } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
export function OAuthButtons() {
|
export function OAuthButtons() {
|
||||||
const [providers, setProviders] = useState<string[]>([])
|
const [providers, setProviders] = useState<string[]>([])
|
||||||
@@ -10,7 +10,7 @@ export function OAuthButtons() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProviders = async () => {
|
const fetchProviders = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiService.getOAuthProviders()
|
const response = await api.auth.getOAuthProviders()
|
||||||
setProviders(response.providers)
|
setProviders(response.providers)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch OAuth providers:', error)
|
console.error('Failed to fetch OAuth providers:', error)
|
||||||
@@ -23,7 +23,7 @@ export function OAuthButtons() {
|
|||||||
const handleOAuthLogin = async (provider: string) => {
|
const handleOAuthLogin = async (provider: string) => {
|
||||||
setLoading(provider)
|
setLoading(provider)
|
||||||
try {
|
try {
|
||||||
const response = await apiService.getOAuthUrl(provider)
|
const response = await api.auth.getOAuthUrl(provider)
|
||||||
|
|
||||||
// Store state in sessionStorage for verification
|
// Store state in sessionStorage for verification
|
||||||
sessionStorage.setItem('oauth_state', response.state)
|
sessionStorage.setItem('oauth_state', response.state)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
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 { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTheme } from "next-themes"
|
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 Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { apiService } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth'
|
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth'
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null)
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
@@ -24,7 +24,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
try {
|
try {
|
||||||
// Try to get user info using cookies
|
// Try to get user info using cookies
|
||||||
const user = await apiService.getMe()
|
const user = await api.auth.getMe()
|
||||||
setUser(user)
|
setUser(user)
|
||||||
} catch {
|
} catch {
|
||||||
// User is not authenticated - this is normal for logged out users
|
// User is not authenticated - this is normal for logged out users
|
||||||
@@ -36,33 +36,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = async (credentials: LoginRequest) => {
|
const login = async (credentials: LoginRequest) => {
|
||||||
try {
|
const user = await api.auth.login(credentials)
|
||||||
const response = await apiService.login(credentials)
|
setUser(user)
|
||||||
setUser(response.user)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const register = async (data: RegisterRequest) => {
|
const register = async (data: RegisterRequest) => {
|
||||||
try {
|
const user = await api.auth.register(data)
|
||||||
const response = await apiService.register(data)
|
setUser(user)
|
||||||
setUser(response.user)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration failed:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await apiService.logout()
|
await api.auth.logout()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
user,
|
user,
|
||||||
token: user ? 'cookie-based' : null,
|
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
212
src/lib/api.ts
212
src/lib/api.ts
@@ -1,209 +1,5 @@
|
|||||||
import type { LoginRequest, RegisterRequest } from '@/types/auth'
|
// Main API exports - using the new modular API structure
|
||||||
|
export * from './api/index'
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost:8000'
|
// Export the main API object as default
|
||||||
|
export { default as api } from './api/index'
|
||||||
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<void> | null = null
|
|
||||||
|
|
||||||
private async request<T>(
|
|
||||||
endpoint: string,
|
|
||||||
options: RequestInit = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${API_BASE_URL}${endpoint}`
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers as Record<string, string>),
|
|
||||||
}
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
if (this.refreshPromise) {
|
|
||||||
await this.refreshPromise
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.refreshPromise = this.performTokenRefresh()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.refreshPromise
|
|
||||||
} finally {
|
|
||||||
this.refreshPromise = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performTokenRefresh(): Promise<void> {
|
|
||||||
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<User> {
|
|
||||||
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 }
|
|
||||||
194
src/lib/api/client.ts
Normal file
194
src/lib/api/client.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { API_CONFIG } from './config'
|
||||||
|
import { createApiError, NetworkError, TimeoutError } from './errors'
|
||||||
|
import type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
|
||||||
|
|
||||||
|
export class BaseApiClient implements ApiClient {
|
||||||
|
private refreshPromise: Promise<void> | null = null
|
||||||
|
private baseURL: string
|
||||||
|
|
||||||
|
constructor(baseURL: string = API_CONFIG.BASE_URL) {
|
||||||
|
this.baseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildURL(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const url = new URL(endpoint, this.baseURL)
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
url.searchParams.append(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
method: HttpMethod,
|
||||||
|
endpoint: string,
|
||||||
|
data?: unknown,
|
||||||
|
config: ApiRequestConfig = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
params,
|
||||||
|
skipAuth = false,
|
||||||
|
timeout = API_CONFIG.TIMEOUT,
|
||||||
|
headers: customHeaders,
|
||||||
|
...fetchConfig
|
||||||
|
} = config
|
||||||
|
|
||||||
|
const url = this.buildURL(endpoint, params)
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(customHeaders as Record<string, string>),
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: skipAuth ? 'omit' : 'include',
|
||||||
|
...fetchConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||||
|
if (data instanceof FormData) {
|
||||||
|
// Remove Content-Type header for FormData to let browser set it with boundary
|
||||||
|
delete headers['Content-Type']
|
||||||
|
requestConfig.body = data
|
||||||
|
} else {
|
||||||
|
requestConfig.body = JSON.stringify(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||||
|
requestConfig.signal = controller.signal
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, requestConfig)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 && !skipAuth) {
|
||||||
|
try {
|
||||||
|
await this.handleTokenRefresh()
|
||||||
|
// Retry the original request
|
||||||
|
const retryResponse = await fetch(url, requestConfig)
|
||||||
|
|
||||||
|
if (!retryResponse.ok) {
|
||||||
|
const errorData = await this.safeParseJSON(retryResponse)
|
||||||
|
throw createApiError(retryResponse, errorData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.safeParseJSON(retryResponse) as T
|
||||||
|
} catch (refreshError) {
|
||||||
|
this.handleAuthenticationFailure()
|
||||||
|
throw refreshError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = await this.safeParseJSON(response)
|
||||||
|
throw createApiError(response, errorData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty responses (204 No Content, etc.)
|
||||||
|
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.safeParseJSON(response) as T
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if ((error as Error).name === 'AbortError') {
|
||||||
|
throw new TimeoutError()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
|
throw new NetworkError()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeParseJSON(response: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const text = await response.text()
|
||||||
|
return text ? JSON.parse(text) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTokenRefresh(): Promise<void> {
|
||||||
|
if (this.refreshPromise) {
|
||||||
|
await this.refreshPromise
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshPromise = this.performTokenRefresh()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshPromise
|
||||||
|
} finally {
|
||||||
|
this.refreshPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performTokenRefresh(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createApiError(response, await this.safeParseJSON(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAuthenticationFailure(): void {
|
||||||
|
// Only redirect if we're not already on auth pages to prevent infinite loops
|
||||||
|
const currentPath = window.location.pathname
|
||||||
|
const authPaths = ['/login', '/register', '/auth/callback']
|
||||||
|
|
||||||
|
if (!authPaths.includes(currentPath)) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods
|
||||||
|
async get<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> {
|
||||||
|
return this.request<T>('GET', endpoint, undefined, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
||||||
|
return this.request<T>('POST', endpoint, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
||||||
|
return this.request<T>('PUT', endpoint, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
||||||
|
return this.request<T>('PATCH', endpoint, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> {
|
||||||
|
return this.request<T>('DELETE', endpoint, undefined, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility methods
|
||||||
|
setBaseURL(baseURL: string): void {
|
||||||
|
this.baseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseURL(): string {
|
||||||
|
return this.baseURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default API client instance
|
||||||
|
export const apiClient = new BaseApiClient()
|
||||||
23
src/lib/api/config.ts
Normal file
23
src/lib/api/config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// API Configuration
|
||||||
|
export const API_CONFIG = {
|
||||||
|
BASE_URL: 'http://localhost:8000',
|
||||||
|
TIMEOUT: 30000, // 30 seconds
|
||||||
|
RETRY_ATTEMPTS: 1,
|
||||||
|
|
||||||
|
// API Endpoints
|
||||||
|
ENDPOINTS: {
|
||||||
|
AUTH: {
|
||||||
|
LOGIN: '/api/v1/auth/login',
|
||||||
|
REGISTER: '/api/v1/auth/register',
|
||||||
|
LOGOUT: '/api/v1/auth/logout',
|
||||||
|
REFRESH: '/api/v1/auth/refresh',
|
||||||
|
ME: '/api/v1/auth/me',
|
||||||
|
PROVIDERS: '/api/v1/auth/providers',
|
||||||
|
OAUTH_AUTHORIZE: (provider: string) => `/api/v1/auth/${provider}/authorize`,
|
||||||
|
OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`,
|
||||||
|
EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
|
||||||
98
src/lib/api/errors.ts
Normal file
98
src/lib/api/errors.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// API Error Classes
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public status: number
|
||||||
|
public response?: unknown
|
||||||
|
public detail?: string
|
||||||
|
|
||||||
|
constructor(message: string, status: number, response?: unknown, detail?: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.response = response
|
||||||
|
this.detail = detail
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromResponse(response: Response, data?: unknown): ApiError {
|
||||||
|
const errorData = data as Record<string, unknown>
|
||||||
|
const message = errorData?.detail as string || errorData?.message as string || `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
return new ApiError(message, response.status, data, errorData?.detail as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetworkError extends ApiError {
|
||||||
|
constructor(message: string = 'Network request failed') {
|
||||||
|
super(message, 0)
|
||||||
|
this.name = 'NetworkError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimeoutError extends ApiError {
|
||||||
|
constructor(message: string = 'Request timeout') {
|
||||||
|
super(message, 408)
|
||||||
|
this.name = 'TimeoutError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends ApiError {
|
||||||
|
public fields?: Record<string, string[]>
|
||||||
|
|
||||||
|
constructor(message: string, fields?: Record<string, string[]>) {
|
||||||
|
super(message, 422)
|
||||||
|
this.name = 'ValidationError'
|
||||||
|
this.fields = fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthenticationError extends ApiError {
|
||||||
|
constructor(message: string = 'Authentication required') {
|
||||||
|
super(message, 401)
|
||||||
|
this.name = 'AuthenticationError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthorizationError extends ApiError {
|
||||||
|
constructor(message: string = 'Access denied') {
|
||||||
|
super(message, 403)
|
||||||
|
this.name = 'AuthorizationError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends ApiError {
|
||||||
|
constructor(message: string = 'Resource not found') {
|
||||||
|
super(message, 404)
|
||||||
|
this.name = 'NotFoundError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerError extends ApiError {
|
||||||
|
constructor(message: string = 'Internal server error') {
|
||||||
|
super(message, 500)
|
||||||
|
this.name = 'ServerError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error factory function
|
||||||
|
export function createApiError(response: Response, data?: unknown): ApiError {
|
||||||
|
const status = response.status
|
||||||
|
const errorData = data as Record<string, unknown>
|
||||||
|
const message = errorData?.detail as string || errorData?.message as string || `HTTP ${status}: ${response.statusText}`
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
return new AuthenticationError(message)
|
||||||
|
case 403:
|
||||||
|
return new AuthorizationError(message)
|
||||||
|
case 404:
|
||||||
|
return new NotFoundError(message)
|
||||||
|
case 422:
|
||||||
|
return new ValidationError(message, errorData?.fields as Record<string, string[]>)
|
||||||
|
case 500:
|
||||||
|
case 501:
|
||||||
|
case 502:
|
||||||
|
case 503:
|
||||||
|
case 504:
|
||||||
|
return new ServerError(message)
|
||||||
|
default:
|
||||||
|
return new ApiError(message, status, data, errorData?.detail as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/lib/api/index.ts
Normal file
20
src/lib/api/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Re-export all API services and utilities
|
||||||
|
export * from './client'
|
||||||
|
export * from './config'
|
||||||
|
export * from './types'
|
||||||
|
export * from './errors'
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export * from './services/auth'
|
||||||
|
|
||||||
|
// Main API object for convenient access
|
||||||
|
import { authService } from './services/auth'
|
||||||
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
auth: authService,
|
||||||
|
client: apiClient,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Default export for convenience
|
||||||
|
export default api
|
||||||
155
src/lib/api/services/auth.ts
Normal file
155
src/lib/api/services/auth.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type { User } from '@/types/auth'
|
||||||
|
import { apiClient } from '../client'
|
||||||
|
import { API_CONFIG } from '../config'
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthProvider {
|
||||||
|
name: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthAuthorizationResponse {
|
||||||
|
authorization_url: string
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthProvidersResponse {
|
||||||
|
providers: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeOAuthTokenRequest {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeOAuthTokenResponse {
|
||||||
|
message: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
/**
|
||||||
|
* Authenticate user with email and password
|
||||||
|
*/
|
||||||
|
async login(credentials: LoginRequest): Promise<User> {
|
||||||
|
// Using direct fetch for auth endpoints to avoid circular dependency with token refresh
|
||||||
|
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credentials),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorData?.detail || 'Login failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user account
|
||||||
|
*/
|
||||||
|
async register(userData: RegisterRequest): Promise<User> {
|
||||||
|
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorData?.detail || 'Registration failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
async getMe(): Promise<User> {
|
||||||
|
return apiClient.get<User>(API_CONFIG.ENDPOINTS.AUTH.ME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout the current user
|
||||||
|
*/
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGOUT}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout request failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OAuth authorization URL for a provider
|
||||||
|
*/
|
||||||
|
async getOAuthUrl(provider: string): Promise<OAuthAuthorizationResponse> {
|
||||||
|
return apiClient.get<OAuthAuthorizationResponse>(
|
||||||
|
API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider),
|
||||||
|
{ skipAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available OAuth providers
|
||||||
|
*/
|
||||||
|
async getOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||||
|
return apiClient.get<OAuthProvidersResponse>(
|
||||||
|
API_CONFIG.ENDPOINTS.AUTH.PROVIDERS,
|
||||||
|
{ skipAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange OAuth temporary code for auth cookies
|
||||||
|
*/
|
||||||
|
async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise<ExchangeOAuthTokenResponse> {
|
||||||
|
// Use direct fetch for OAuth token exchange to avoid auth loops and ensure cookies are set
|
||||||
|
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
credentials: 'include', // Essential for receiving auth cookies
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorData?.detail || 'OAuth token exchange failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh authentication token
|
||||||
|
*/
|
||||||
|
async refreshToken(): Promise<void> {
|
||||||
|
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Token refresh failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService()
|
||||||
34
src/lib/api/types.ts
Normal file
34
src/lib/api/types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Generic API types
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiRequestConfig extends RequestInit {
|
||||||
|
params?: Record<string, string | number | boolean | undefined>
|
||||||
|
skipAuth?: boolean
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// HTTP Methods
|
||||||
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
|
|
||||||
|
// Generic API client interface
|
||||||
|
export interface ApiClient {
|
||||||
|
get<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
|
||||||
|
post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
|
||||||
|
put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
|
||||||
|
patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
|
||||||
|
delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { apiService } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
export function AuthCallbackPage() {
|
export function AuthCallbackPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -20,29 +20,11 @@ export function AuthCallbackPage() {
|
|||||||
throw new Error('No authorization code received')
|
throw new Error('No authorization code received')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Exchanging OAuth code for tokens...')
|
|
||||||
|
|
||||||
// Exchange the temporary code for proper auth cookies
|
// Exchange the temporary code for proper auth cookies
|
||||||
const response = await fetch('http://localhost:8000/api/v1/auth/exchange-oauth-token', {
|
const result = await api.auth.exchangeOAuthToken({ code })
|
||||||
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
|
// Now get the user info
|
||||||
const user = await apiService.getMe()
|
const user = await api.auth.getMe()
|
||||||
console.log('User info retrieved:', user)
|
|
||||||
|
|
||||||
// Update auth context
|
// Update auth context
|
||||||
if (setUser) setUser(user)
|
if (setUser) setUser(user)
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export interface RegisterRequest {
|
|||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: User | null
|
user: User | null
|
||||||
token: string | null
|
|
||||||
login: (credentials: LoginRequest) => Promise<void>
|
login: (credentials: LoginRequest) => Promise<void>
|
||||||
register: (data: RegisterRequest) => Promise<void>
|
register: (data: RegisterRequest) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
|
|||||||
Reference in New Issue
Block a user