Refactor API structure and integrate new modular API client
- Replaced legacy apiService with a new modular api client structure. - Updated AuthContext, OAuthButtons, and AuthCallbackPage to use the new api client. - Created separate services for auth, sounds, playlists, and users. - Implemented centralized API configuration and error handling. - Added support for OAuth providers and token exchange. - Introduced a Toaster component for notifications in App. - Updated API endpoints and request handling for better maintainability.
This commit is contained in:
@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -20,7 +21,6 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
@@ -43,6 +43,7 @@ function App() {
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { apiService } from '@/lib/api'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function OAuthButtons() {
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
@@ -10,7 +10,7 @@ export function OAuthButtons() {
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const response = await apiService.getOAuthProviders()
|
||||
const response = await api.auth.getOAuthProviders()
|
||||
setProviders(response.providers)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OAuth providers:', error)
|
||||
@@ -23,7 +23,7 @@ export function OAuthButtons() {
|
||||
const handleOAuthLogin = async (provider: string) => {
|
||||
setLoading(provider)
|
||||
try {
|
||||
const response = await apiService.getOAuthUrl(provider)
|
||||
const response = await api.auth.getOAuthUrl(provider)
|
||||
|
||||
// Store state in sessionStorage for verification
|
||||
sessionStorage.setItem('oauth_state', response.state)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
@@ -24,7 +24,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Try to get user info using cookies
|
||||
const user = await apiService.getMe()
|
||||
const user = await api.auth.getMe()
|
||||
setUser(user)
|
||||
} catch {
|
||||
// User is not authenticated - this is normal for logged out users
|
||||
@@ -37,8 +37,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
try {
|
||||
const response = await apiService.login(credentials)
|
||||
setUser(response.user)
|
||||
const user = await api.auth.login(credentials)
|
||||
setUser(user)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
throw error
|
||||
@@ -47,8 +47,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
|
||||
const register = async (data: RegisterRequest) => {
|
||||
try {
|
||||
const response = await apiService.register(data)
|
||||
setUser(response.user)
|
||||
const user = await api.auth.register(data)
|
||||
setUser(user)
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error)
|
||||
throw error
|
||||
@@ -56,7 +56,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await apiService.logout()
|
||||
await api.auth.logout()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
|
||||
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'
|
||||
|
||||
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 }
|
||||
// Export the main API object as default
|
||||
export { default as api } from './api/index'
|
||||
214
src/lib/api/README.md
Normal file
214
src/lib/api/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# API Library
|
||||
|
||||
A generic, maintainable API client library for the Soundboard application.
|
||||
|
||||
## Features
|
||||
|
||||
- **Generic HTTP Client**: Supports all REST methods with proper TypeScript typing
|
||||
- **Automatic Token Refresh**: Handles JWT token refresh automatically
|
||||
- **Error Handling**: Comprehensive error classes for different scenarios
|
||||
- **Modular Services**: Separate services for different API domains
|
||||
- **Configuration Management**: Centralized API configuration
|
||||
- **Backward Compatibility**: Legacy API service for existing code
|
||||
|
||||
## Usage
|
||||
|
||||
### New API (Recommended)
|
||||
|
||||
```typescript
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
// Authentication
|
||||
const user = await api.auth.login({ email: 'user@example.com', password: 'password' })
|
||||
const currentUser = await api.auth.getMe()
|
||||
await api.auth.logout()
|
||||
|
||||
// Sounds
|
||||
const sounds = await api.sounds.list({ page: 1, size: 20 })
|
||||
const sound = await api.sounds.get(1)
|
||||
const newSound = await api.sounds.create({ title: 'My Sound', file: audioFile })
|
||||
|
||||
// Playlists
|
||||
const playlists = await api.playlists.list()
|
||||
const playlist = await api.playlists.create({ name: 'My Playlist' })
|
||||
|
||||
// Users (admin only)
|
||||
const users = await api.users.list()
|
||||
```
|
||||
|
||||
### Alternative Import Style
|
||||
|
||||
```typescript
|
||||
import { authService, soundsService } from '@/lib/api'
|
||||
|
||||
// Direct service imports
|
||||
const user = await authService.login(credentials)
|
||||
const sounds = await soundsService.list()
|
||||
```
|
||||
|
||||
### Direct Client Usage
|
||||
|
||||
```typescript
|
||||
import { apiClient } from '@/lib/api'
|
||||
|
||||
// Generic HTTP requests
|
||||
const data = await apiClient.get<MyType>('/api/v1/custom-endpoint')
|
||||
const result = await apiClient.post<ResponseType>('/api/v1/data', requestData)
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
### AuthService (`api.auth`)
|
||||
|
||||
- `login(credentials)` - Authenticate user
|
||||
- `register(userData)` - Register new user
|
||||
- `getMe()` - Get current user
|
||||
- `logout()` - Sign out user
|
||||
- `getOAuthUrl(provider)` - Get OAuth authorization URL
|
||||
- `getOAuthProviders()` - Get available OAuth providers
|
||||
- `exchangeOAuthToken(request)` - Exchange OAuth code for auth cookies
|
||||
|
||||
### SoundsService (`api.sounds`)
|
||||
|
||||
- `list(params?)` - Get paginated sounds
|
||||
- `get(id)` - Get specific sound
|
||||
- `create(data)` - Create new sound
|
||||
- `update(id, data)` - Update sound
|
||||
- `delete(id)` - Delete sound
|
||||
- `upload(file, metadata?)` - Upload sound file
|
||||
|
||||
### PlaylistsService (`api.playlists`)
|
||||
|
||||
- `list(params?)` - Get paginated playlists
|
||||
- `get(id)` - Get specific playlist
|
||||
- `create(data)` - Create new playlist
|
||||
- `update(id, data)` - Update playlist
|
||||
- `delete(id)` - Delete playlist
|
||||
- `addSound(playlistId, soundData)` - Add sound to playlist
|
||||
- `removeSound(playlistId, soundId)` - Remove sound from playlist
|
||||
|
||||
### UsersService (`api.users`)
|
||||
|
||||
- `list(params?)` - Get paginated users (admin only)
|
||||
- `get(id)` - Get specific user
|
||||
- `update(id, data)` - Update user
|
||||
- `delete(id)` - Delete user (admin only)
|
||||
- `changePassword(userId, data)` - Change user password
|
||||
- `uploadAvatar(userId, file)` - Upload user avatar
|
||||
|
||||
## Error Handling
|
||||
|
||||
The library provides specific error classes for different scenarios:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ApiError,
|
||||
NetworkError,
|
||||
TimeoutError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
NotFoundError,
|
||||
ServerError
|
||||
} from '@/lib/api'
|
||||
|
||||
try {
|
||||
await api.auth.login(credentials)
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
// Handle login failure
|
||||
} else if (error instanceof ValidationError) {
|
||||
// Handle validation errors
|
||||
console.log(error.fields) // Field-specific errors
|
||||
} else if (error instanceof NetworkError) {
|
||||
// Handle network issues
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
API configuration is centralized in `config.ts`:
|
||||
|
||||
```typescript
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
|
||||
TIMEOUT: 30000,
|
||||
RETRY_ATTEMPTS: 1,
|
||||
ENDPOINTS: {
|
||||
AUTH: { /* ... */ },
|
||||
SOUNDS: { /* ... */ },
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Request Configuration
|
||||
|
||||
All API methods accept optional configuration:
|
||||
|
||||
```typescript
|
||||
// Custom timeout
|
||||
await api.sounds.list({}, { timeout: 60000 })
|
||||
|
||||
// Skip authentication
|
||||
await api.auth.getOAuthProviders({}, { skipAuth: true })
|
||||
|
||||
// Custom headers
|
||||
await api.sounds.create(data, {
|
||||
headers: { 'X-Custom-Header': 'value' }
|
||||
})
|
||||
|
||||
// Query parameters
|
||||
await api.sounds.list({}, {
|
||||
params: { search: 'query', page: 1 }
|
||||
})
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/lib/api/
|
||||
├── index.ts # Main exports
|
||||
├── client.ts # Core HTTP client
|
||||
├── config.ts # API configuration
|
||||
├── types.ts # TypeScript types
|
||||
├── errors.ts # Error classes
|
||||
├── services/
|
||||
│ ├── auth.ts # Authentication service
|
||||
│ ├── sounds.ts # Sounds service
|
||||
│ ├── playlists.ts # Playlists service
|
||||
│ └── users.ts # Users service
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If migrating from an older API structure:
|
||||
|
||||
1. **Use the main API object:**
|
||||
```typescript
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
// Authentication
|
||||
const user = await api.auth.login(credentials)
|
||||
|
||||
// Resources
|
||||
const sounds = await api.sounds.list()
|
||||
const playlists = await api.playlists.list()
|
||||
```
|
||||
|
||||
2. **Or import services directly:**
|
||||
```typescript
|
||||
import { authService, soundsService } from '@/lib/api'
|
||||
|
||||
const user = await authService.login(credentials)
|
||||
const sounds = await soundsService.list()
|
||||
```
|
||||
|
||||
3. **For custom requests, use the client:**
|
||||
```typescript
|
||||
import { apiClient } from '@/lib/api'
|
||||
|
||||
const data = await apiClient.get<MyType>('/api/v1/custom-endpoint')
|
||||
```
|
||||
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)
|
||||
} 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)
|
||||
} 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()
|
||||
44
src/lib/api/config.ts
Normal file
44
src/lib/api/config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// API Configuration
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: import.meta.env.VITE_API_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',
|
||||
},
|
||||
SOUNDS: {
|
||||
LIST: '/api/v1/sounds',
|
||||
CREATE: '/api/v1/sounds',
|
||||
GET: (id: string | number) => `/api/v1/sounds/${id}`,
|
||||
UPDATE: (id: string | number) => `/api/v1/sounds/${id}`,
|
||||
DELETE: (id: string | number) => `/api/v1/sounds/${id}`,
|
||||
UPLOAD: '/api/v1/sounds/upload',
|
||||
},
|
||||
PLAYLISTS: {
|
||||
LIST: '/api/v1/playlists',
|
||||
CREATE: '/api/v1/playlists',
|
||||
GET: (id: string | number) => `/api/v1/playlists/${id}`,
|
||||
UPDATE: (id: string | number) => `/api/v1/playlists/${id}`,
|
||||
DELETE: (id: string | number) => `/api/v1/playlists/${id}`,
|
||||
},
|
||||
USERS: {
|
||||
LIST: '/api/v1/users',
|
||||
GET: (id: string | number) => `/api/v1/users/${id}`,
|
||||
UPDATE: (id: string | number) => `/api/v1/users/${id}`,
|
||||
DELETE: (id: string | number) => `/api/v1/users/${id}`,
|
||||
},
|
||||
},
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
29
src/lib/api/index.ts
Normal file
29
src/lib/api/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Re-export all API services and utilities
|
||||
export * from './client'
|
||||
export * from './config'
|
||||
export * from './types'
|
||||
export * from './errors'
|
||||
|
||||
// Services
|
||||
export * from './services/auth'
|
||||
export * from './services/sounds'
|
||||
export * from './services/playlists'
|
||||
export * from './services/users'
|
||||
|
||||
// Main API object for convenient access
|
||||
import { authService } from './services/auth'
|
||||
import { soundsService } from './services/sounds'
|
||||
import { playlistsService } from './services/playlists'
|
||||
import { usersService } from './services/users'
|
||||
import { apiClient } from './client'
|
||||
|
||||
export const api = {
|
||||
auth: authService,
|
||||
sounds: soundsService,
|
||||
playlists: playlistsService,
|
||||
users: usersService,
|
||||
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()
|
||||
94
src/lib/api/services/playlists.ts
Normal file
94
src/lib/api/services/playlists.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { apiClient } from '../client'
|
||||
import { API_CONFIG } from '../config'
|
||||
import type { PaginatedResponse } from '../types'
|
||||
import type { Sound } from './sounds'
|
||||
|
||||
export interface Playlist {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
user_id: number
|
||||
is_public: boolean
|
||||
sounds: Sound[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreatePlaylistRequest {
|
||||
name: string
|
||||
description?: string
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
export interface UpdatePlaylistRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
export interface PlaylistsListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
search?: string
|
||||
user_id?: number
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
export interface AddSoundToPlaylistRequest {
|
||||
sound_id: number
|
||||
}
|
||||
|
||||
export class PlaylistsService {
|
||||
/**
|
||||
* Get list of playlists with pagination
|
||||
*/
|
||||
async list(params?: PlaylistsListParams): Promise<PaginatedResponse<Playlist>> {
|
||||
return apiClient.get<PaginatedResponse<Playlist>>(API_CONFIG.ENDPOINTS.PLAYLISTS.LIST, {
|
||||
params: params as Record<string, string | number | boolean | undefined>
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific playlist by ID
|
||||
*/
|
||||
async get(id: string | number): Promise<Playlist> {
|
||||
return apiClient.get<Playlist>(API_CONFIG.ENDPOINTS.PLAYLISTS.GET(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new playlist
|
||||
*/
|
||||
async create(data: CreatePlaylistRequest): Promise<Playlist> {
|
||||
return apiClient.post<Playlist>(API_CONFIG.ENDPOINTS.PLAYLISTS.CREATE, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing playlist
|
||||
*/
|
||||
async update(id: string | number, data: UpdatePlaylistRequest): Promise<Playlist> {
|
||||
return apiClient.patch<Playlist>(API_CONFIG.ENDPOINTS.PLAYLISTS.UPDATE(id), data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a playlist
|
||||
*/
|
||||
async delete(id: string | number): Promise<void> {
|
||||
return apiClient.delete<void>(API_CONFIG.ENDPOINTS.PLAYLISTS.DELETE(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sound to a playlist
|
||||
*/
|
||||
async addSound(playlistId: string | number, data: AddSoundToPlaylistRequest): Promise<Playlist> {
|
||||
return apiClient.post<Playlist>(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a sound from a playlist
|
||||
*/
|
||||
async removeSound(playlistId: string | number, soundId: string | number): Promise<Playlist> {
|
||||
return apiClient.delete<Playlist>(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds/${soundId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const playlistsService = new PlaylistsService()
|
||||
100
src/lib/api/services/sounds.ts
Normal file
100
src/lib/api/services/sounds.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { apiClient } from '../client'
|
||||
import { API_CONFIG } from '../config'
|
||||
import type { PaginatedResponse } from '../types'
|
||||
|
||||
export interface Sound {
|
||||
id: number
|
||||
title: string
|
||||
description?: string
|
||||
file_url: string
|
||||
duration: number
|
||||
file_size: number
|
||||
mime_type: string
|
||||
play_count: number
|
||||
user_id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateSoundRequest {
|
||||
title: string
|
||||
description?: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface UpdateSoundRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface SoundsListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
search?: string
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
export class SoundsService {
|
||||
/**
|
||||
* Get list of sounds with pagination
|
||||
*/
|
||||
async list(params?: SoundsListParams): Promise<PaginatedResponse<Sound>> {
|
||||
return apiClient.get<PaginatedResponse<Sound>>(API_CONFIG.ENDPOINTS.SOUNDS.LIST, {
|
||||
params: params as Record<string, string | number | boolean | undefined>
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific sound by ID
|
||||
*/
|
||||
async get(id: string | number): Promise<Sound> {
|
||||
return apiClient.get<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.GET(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new sound
|
||||
*/
|
||||
async create(data: CreateSoundRequest): Promise<Sound> {
|
||||
const formData = new FormData()
|
||||
formData.append('title', data.title)
|
||||
if (data.description) {
|
||||
formData.append('description', data.description)
|
||||
}
|
||||
formData.append('file', data.file)
|
||||
|
||||
return apiClient.post<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.CREATE, formData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing sound
|
||||
*/
|
||||
async update(id: string | number, data: UpdateSoundRequest): Promise<Sound> {
|
||||
return apiClient.patch<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.UPDATE(id), data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a sound
|
||||
*/
|
||||
async delete(id: string | number): Promise<void> {
|
||||
return apiClient.delete<void>(API_CONFIG.ENDPOINTS.SOUNDS.DELETE(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a sound file
|
||||
*/
|
||||
async upload(file: File, metadata?: { title?: string; description?: string }): Promise<Sound> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
if (metadata?.title) {
|
||||
formData.append('title', metadata.title)
|
||||
}
|
||||
if (metadata?.description) {
|
||||
formData.append('description', metadata.description)
|
||||
}
|
||||
|
||||
return apiClient.post<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.UPLOAD, formData)
|
||||
}
|
||||
}
|
||||
|
||||
export const soundsService = new SoundsService()
|
||||
74
src/lib/api/services/users.ts
Normal file
74
src/lib/api/services/users.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { User } from '@/types/auth'
|
||||
import { apiClient } from '../client'
|
||||
import { API_CONFIG } from '../config'
|
||||
import type { PaginatedResponse } from '../types'
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name?: string
|
||||
email?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
export interface UsersListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
search?: string
|
||||
role?: string
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export class UsersService {
|
||||
/**
|
||||
* Get list of users with pagination (admin only)
|
||||
*/
|
||||
async list(params?: UsersListParams): Promise<PaginatedResponse<User>> {
|
||||
return apiClient.get<PaginatedResponse<User>>(API_CONFIG.ENDPOINTS.USERS.LIST, {
|
||||
params: params as Record<string, string | number | boolean | undefined>
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific user by ID
|
||||
*/
|
||||
async get(id: string | number): Promise<User> {
|
||||
return apiClient.get<User>(API_CONFIG.ENDPOINTS.USERS.GET(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
async update(id: string | number, data: UpdateUserRequest): Promise<User> {
|
||||
return apiClient.patch<User>(API_CONFIG.ENDPOINTS.USERS.UPDATE(id), data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user (admin only)
|
||||
*/
|
||||
async delete(id: string | number): Promise<void> {
|
||||
return apiClient.delete<void>(API_CONFIG.ENDPOINTS.USERS.DELETE(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changePassword(userId: string | number, data: ChangePasswordRequest): Promise<void> {
|
||||
return apiClient.post<void>(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/change-password`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload user avatar
|
||||
*/
|
||||
async uploadAvatar(userId: string | number, file: File): Promise<User> {
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
|
||||
return apiClient.post<User>(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/avatar`, formData)
|
||||
}
|
||||
}
|
||||
|
||||
export const usersService = new UsersService()
|
||||
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 { useNavigate } from 'react-router'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { apiService } from '@/lib/api'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function AuthCallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -23,25 +23,11 @@ export function AuthCallbackPage() {
|
||||
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()
|
||||
const result = await api.auth.exchangeOAuthToken({ code })
|
||||
console.log('Token exchange successful:', result)
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user