diff --git a/src/App.tsx b/src/App.tsx index 2932da7..d40b79f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + ) diff --git a/src/components/auth/OAuthButtons.tsx b/src/components/auth/OAuthButtons.tsx index cadab5e..ae33ec3 100644 --- a/src/components/auth/OAuthButtons.tsx +++ b/src/components/auth/OAuthButtons.tsx @@ -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([]) @@ -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) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index a3e425e..9fad5d6 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -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(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) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 0520991..d177983 100644 --- a/src/lib/api.ts +++ b/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 | null = null - - private async request( - endpoint: string, - options: RequestInit = {} - ): Promise { - const url = `${API_BASE_URL}${endpoint}` - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - } - - const config: RequestInit = { - ...options, - headers, - credentials: 'include', // Always include cookies - } - - try { - const response = await fetch(url, config) - - if (!response.ok) { - if (response.status === 401) { - try { - await this.refreshToken() - const retryResponse = await fetch(url, config) - - if (!retryResponse.ok) { - throw new ApiError( - 'Request failed after token refresh', - retryResponse.status, - await retryResponse.json().catch(() => null) - ) - } - - return await retryResponse.json() - } catch (refreshError) { - // Only redirect if we're not already on the login page to prevent infinite loops - const currentPath = window.location.pathname - if (currentPath !== '/login' && currentPath !== '/register') { - window.location.href = '/login' - } - throw refreshError - } - } - - const errorData = await response.json().catch(() => null) - throw new ApiError( - errorData?.detail || 'Request failed', - response.status, - errorData - ) - } - - return await response.json() - } catch (error) { - if (error instanceof ApiError) { - throw error - } - throw new ApiError('Network error', 0) - } - } - - private async refreshToken(): Promise { - if (this.refreshPromise) { - await this.refreshPromise - return - } - - this.refreshPromise = this.performTokenRefresh() - - try { - await this.refreshPromise - } finally { - this.refreshPromise = null - } - } - - private async performTokenRefresh(): Promise { - const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // Send cookies with refresh token - }) - - if (!response.ok) { - throw new ApiError('Token refresh failed', response.status) - } - - // Token is automatically set in cookies by the backend - // No need to handle it manually - } - - async login(credentials: LoginRequest): Promise<{ user: User }> { - const response = await fetch(`${API_BASE_URL}/api/v1/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - credentials: 'include', // Important for cookies - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => null) - throw new ApiError( - errorData?.detail || 'Login failed', - response.status, - errorData - ) - } - - const user = await response.json() - - return { user } - } - - async register(userData: RegisterRequest): Promise<{ user: User }> { - const response = await fetch(`${API_BASE_URL}/api/v1/auth/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(userData), - credentials: 'include', // Important for cookies - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => null) - throw new ApiError( - errorData?.detail || 'Registration failed', - response.status, - errorData - ) - } - - const user = await response.json() - - return { user } - } - - async getMe(): Promise { - return this.request('/api/v1/auth/me') - } - - async logout() { - try { - await fetch(`${API_BASE_URL}/api/v1/auth/logout`, { - method: 'POST', - credentials: 'include', - }) - } catch (error) { - console.error('Logout request failed:', error) - } - } - - async getOAuthUrl(provider: string): Promise<{ authorization_url: string; state: string }> { - const response = await fetch(`${API_BASE_URL}/api/v1/auth/${provider}/authorize`, { - method: 'GET', - credentials: 'include', - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => null) - throw new ApiError( - errorData?.detail || 'Failed to get OAuth URL', - response.status, - errorData - ) - } - - return await response.json() - } - - async getOAuthProviders(): Promise<{ providers: string[] }> { - const response = await fetch(`${API_BASE_URL}/api/v1/auth/providers`, { - method: 'GET', - }) - - if (!response.ok) { - throw new ApiError('Failed to get OAuth providers', response.status) - } - - return await response.json() - } - -} - -export const apiService = new ApiService() -export { ApiError } \ No newline at end of file +// Export the main API object as default +export { default as api } from './api/index' \ No newline at end of file diff --git a/src/lib/api/README.md b/src/lib/api/README.md new file mode 100644 index 0000000..e6af9c7 --- /dev/null +++ b/src/lib/api/README.md @@ -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('/api/v1/custom-endpoint') +const result = await apiClient.post('/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('/api/v1/custom-endpoint') + ``` \ No newline at end of file diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..5910a76 --- /dev/null +++ b/src/lib/api/client.ts @@ -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 | null = null + private baseURL: string + + constructor(baseURL: string = API_CONFIG.BASE_URL) { + this.baseURL = baseURL + } + + private buildURL(endpoint: string, params?: Record): 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( + method: HttpMethod, + endpoint: string, + data?: unknown, + config: ApiRequestConfig = {} + ): Promise { + const { + params, + skipAuth = false, + timeout = API_CONFIG.TIMEOUT, + headers: customHeaders, + ...fetchConfig + } = config + + const url = this.buildURL(endpoint, params) + const headers: Record = { + 'Content-Type': 'application/json', + ...(customHeaders as Record), + } + + 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 { + try { + const text = await response.text() + return text ? JSON.parse(text) : {} + } catch { + return {} + } + } + + private async handleTokenRefresh(): Promise { + if (this.refreshPromise) { + await this.refreshPromise + return + } + + this.refreshPromise = this.performTokenRefresh() + + try { + await this.refreshPromise + } finally { + this.refreshPromise = null + } + } + + private async performTokenRefresh(): Promise { + const response = await fetch(`${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(endpoint: string, config?: ApiRequestConfig): Promise { + return this.request('GET', endpoint, undefined, config) + } + + async post(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise { + return this.request('POST', endpoint, data, config) + } + + async put(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise { + return this.request('PUT', endpoint, data, config) + } + + async patch(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise { + return this.request('PATCH', endpoint, data, config) + } + + async delete(endpoint: string, config?: ApiRequestConfig): Promise { + return this.request('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() \ No newline at end of file diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts new file mode 100644 index 0000000..1a45836 --- /dev/null +++ b/src/lib/api/config.ts @@ -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 \ No newline at end of file diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts new file mode 100644 index 0000000..edd0efa --- /dev/null +++ b/src/lib/api/errors.ts @@ -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 + 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 + + constructor(message: string, fields?: Record) { + 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 + 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) + 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) + } +} \ No newline at end of file diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..e83efcd --- /dev/null +++ b/src/lib/api/index.ts @@ -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 \ No newline at end of file diff --git a/src/lib/api/services/auth.ts b/src/lib/api/services/auth.ts new file mode 100644 index 0000000..1596da5 --- /dev/null +++ b/src/lib/api/services/auth.ts @@ -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 { + // 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 { + 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 { + return apiClient.get(API_CONFIG.ENDPOINTS.AUTH.ME) + } + + /** + * Logout the current user + */ + async logout(): Promise { + 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 { + return apiClient.get( + API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider), + { skipAuth: true } + ) + } + + /** + * Get list of available OAuth providers + */ + async getOAuthProviders(): Promise { + return apiClient.get( + API_CONFIG.ENDPOINTS.AUTH.PROVIDERS, + { skipAuth: true } + ) + } + + /** + * Exchange OAuth temporary code for auth cookies + */ + async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise { + // 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 { + 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() \ No newline at end of file diff --git a/src/lib/api/services/playlists.ts b/src/lib/api/services/playlists.ts new file mode 100644 index 0000000..0df8383 --- /dev/null +++ b/src/lib/api/services/playlists.ts @@ -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> { + return apiClient.get>(API_CONFIG.ENDPOINTS.PLAYLISTS.LIST, { + params: params as Record + }) + } + + /** + * Get a specific playlist by ID + */ + async get(id: string | number): Promise { + return apiClient.get(API_CONFIG.ENDPOINTS.PLAYLISTS.GET(id)) + } + + /** + * Create a new playlist + */ + async create(data: CreatePlaylistRequest): Promise { + return apiClient.post(API_CONFIG.ENDPOINTS.PLAYLISTS.CREATE, data) + } + + /** + * Update an existing playlist + */ + async update(id: string | number, data: UpdatePlaylistRequest): Promise { + return apiClient.patch(API_CONFIG.ENDPOINTS.PLAYLISTS.UPDATE(id), data) + } + + /** + * Delete a playlist + */ + async delete(id: string | number): Promise { + return apiClient.delete(API_CONFIG.ENDPOINTS.PLAYLISTS.DELETE(id)) + } + + /** + * Add a sound to a playlist + */ + async addSound(playlistId: string | number, data: AddSoundToPlaylistRequest): Promise { + return apiClient.post(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds`, data) + } + + /** + * Remove a sound from a playlist + */ + async removeSound(playlistId: string | number, soundId: string | number): Promise { + return apiClient.delete(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds/${soundId}`) + } +} + +export const playlistsService = new PlaylistsService() \ No newline at end of file diff --git a/src/lib/api/services/sounds.ts b/src/lib/api/services/sounds.ts new file mode 100644 index 0000000..604bec6 --- /dev/null +++ b/src/lib/api/services/sounds.ts @@ -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> { + return apiClient.get>(API_CONFIG.ENDPOINTS.SOUNDS.LIST, { + params: params as Record + }) + } + + /** + * Get a specific sound by ID + */ + async get(id: string | number): Promise { + return apiClient.get(API_CONFIG.ENDPOINTS.SOUNDS.GET(id)) + } + + /** + * Create a new sound + */ + async create(data: CreateSoundRequest): Promise { + 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(API_CONFIG.ENDPOINTS.SOUNDS.CREATE, formData) + } + + /** + * Update an existing sound + */ + async update(id: string | number, data: UpdateSoundRequest): Promise { + return apiClient.patch(API_CONFIG.ENDPOINTS.SOUNDS.UPDATE(id), data) + } + + /** + * Delete a sound + */ + async delete(id: string | number): Promise { + return apiClient.delete(API_CONFIG.ENDPOINTS.SOUNDS.DELETE(id)) + } + + /** + * Upload a sound file + */ + async upload(file: File, metadata?: { title?: string; description?: string }): Promise { + 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(API_CONFIG.ENDPOINTS.SOUNDS.UPLOAD, formData) + } +} + +export const soundsService = new SoundsService() \ No newline at end of file diff --git a/src/lib/api/services/users.ts b/src/lib/api/services/users.ts new file mode 100644 index 0000000..9339115 --- /dev/null +++ b/src/lib/api/services/users.ts @@ -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> { + return apiClient.get>(API_CONFIG.ENDPOINTS.USERS.LIST, { + params: params as Record + }) + } + + /** + * Get a specific user by ID + */ + async get(id: string | number): Promise { + return apiClient.get(API_CONFIG.ENDPOINTS.USERS.GET(id)) + } + + /** + * Update user profile + */ + async update(id: string | number, data: UpdateUserRequest): Promise { + return apiClient.patch(API_CONFIG.ENDPOINTS.USERS.UPDATE(id), data) + } + + /** + * Delete a user (admin only) + */ + async delete(id: string | number): Promise { + return apiClient.delete(API_CONFIG.ENDPOINTS.USERS.DELETE(id)) + } + + /** + * Change user password + */ + async changePassword(userId: string | number, data: ChangePasswordRequest): Promise { + return apiClient.post(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/change-password`, data) + } + + /** + * Upload user avatar + */ + async uploadAvatar(userId: string | number, file: File): Promise { + const formData = new FormData() + formData.append('avatar', file) + + return apiClient.post(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/avatar`, formData) + } +} + +export const usersService = new UsersService() \ No newline at end of file diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..28b9adc --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,34 @@ +// Generic API types +export interface ApiResponse { + data?: T + message?: string + error?: string + detail?: string +} + +export interface PaginatedResponse { + items: T[] + total: number + page: number + size: number + pages: number +} + +export interface ApiRequestConfig extends RequestInit { + params?: Record + skipAuth?: boolean + timeout?: number +} + + +// HTTP Methods +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +// Generic API client interface +export interface ApiClient { + get(endpoint: string, config?: ApiRequestConfig): Promise + post(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise + put(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise + patch(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise + delete(endpoint: string, config?: ApiRequestConfig): Promise +} \ No newline at end of file diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx index 06bae9b..202e12d 100644 --- a/src/pages/AuthCallbackPage.tsx +++ b/src/pages/AuthCallbackPage.tsx @@ -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