diff --git a/src/App.tsx b/src/App.tsx index fc8915b..b9aaa10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { ThemeProvider } from './components/ThemeProvider' function App() { return ( - + diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index ee5f2db..51678ed 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -23,7 +23,7 @@ const ThemeProviderContext = createContext(initialState) export function ThemeProvider({ children, defaultTheme = "system", - storageKey = "vite-ui-theme", + storageKey = "theme", ...props }: ThemeProviderProps) { const [theme, setTheme] = useState( diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 28e1f2e..733b631 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -20,7 +20,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const currentUser = await authService.getCurrentUser() setUser(currentUser) - } catch (error) { + } catch { setUser(null) } } @@ -31,7 +31,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { setLoading(false) } + // Listen for refresh token expiration events + const handleRefreshTokenExpired = () => { + console.log('Refresh token expired, logging out user') + setUser(null) + } + + window.addEventListener('auth:refresh-token-expired', handleRefreshTokenExpired) initAuth() + + return () => { + window.removeEventListener('auth:refresh-token-expired', handleRefreshTokenExpired) + } }, []) // Handle OAuth redirect - only run once when page loads diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 212f41f..610b95b 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -10,6 +10,7 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useAuth } from '@/contexts/AuthContext' +import { apiService } from '@/services/api' import { useEffect, useRef, useState } from 'react' export function AccountPage() { @@ -69,13 +70,8 @@ export function AccountPage() { setMessage('') try { - const response = await fetch('http://localhost:5000/api/auth/profile', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ name: name.trim() }), + const response = await apiService.patch('/api/auth/profile', { + name: name.trim(), }) const data = await response.json() @@ -100,12 +96,8 @@ export function AccountPage() { setMessage('') try { - const response = await fetch( - 'http://localhost:5000/api/auth/regenerate-api-token', - { - method: 'POST', - credentials: 'include', - }, + const response = await apiService.post( + '/api/auth/regenerate-api-token' ) const data = await response.json() @@ -188,19 +180,12 @@ export function AccountPage() { setMessage('') try { - const requestBody: any = { new_password: newPassword } + const requestBody: { new_password: string; current_password?: string } = { new_password: newPassword } if (loggedInViaPassword) { requestBody.current_password = currentPassword } - const response = await fetch('http://localhost:5000/api/auth/password', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(requestBody), - }) + const response = await apiService.put('/api/auth/password', requestBody) const data = await response.json() diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..8b584bd --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,150 @@ +/** + * API service with automatic token refresh functionality + */ + +interface ApiRequestInit extends RequestInit { + skipRetry?: boolean // Flag to prevent infinite retry loops +} + +class ApiService { + private baseURL = 'http://localhost:5000' + private isRefreshing = false + private refreshPromise: Promise | null = null + + /** + * Main API request method with automatic token refresh + */ + async request(endpoint: string, options: ApiRequestInit = {}): Promise { + const url = `${this.baseURL}${endpoint}` + const config: RequestInit = { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + } + + try { + const response = await fetch(url, config) + + // If request succeeds or it's not a 401, return response + if (response.ok || response.status !== 401 || options.skipRetry) { + return response + } + + // Handle 401 - try to refresh token + const refreshed = await this.handleTokenRefresh() + + if (refreshed) { + // Retry the original request once + return this.request(endpoint, { ...options, skipRetry: true }) + } else { + // Refresh failed, return the original 401 response + return response + } + } catch (error) { + console.error('API request failed:', error) + throw error + } + } + + /** + * Handle token refresh with deduplication + */ + private async handleTokenRefresh(): Promise { + // If already refreshing, wait for the current refresh to complete + if (this.isRefreshing && this.refreshPromise) { + return this.refreshPromise + } + + // Start new refresh process + this.isRefreshing = true + this.refreshPromise = this.performTokenRefresh() + + try { + const result = await this.refreshPromise + return result + } finally { + this.isRefreshing = false + this.refreshPromise = null + } + } + + /** + * Perform the actual token refresh + */ + private async performTokenRefresh(): Promise { + try { + const response = await fetch(`${this.baseURL}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', + }) + + if (response.ok) { + console.log('Token refreshed successfully') + return true + } else { + console.log('Token refresh failed:', response.status) + + // If refresh token is also expired (401), trigger logout + if (response.status === 401) { + this.handleLogout() + } + return false + } + } catch (error) { + console.error('Token refresh error:', error) + return false + } + } + + /** + * Handle logout when refresh token expires + */ + private handleLogout() { + // Clear any local storage or state if needed + console.log('Refresh token expired, user needs to login again') + + // Dispatch a custom event that the AuthContext can listen to + window.dispatchEvent(new CustomEvent('auth:refresh-token-expired')) + } + + /** + * Convenience methods for common HTTP verbs + */ + async get(endpoint: string, options: ApiRequestInit = {}) { + return this.request(endpoint, { ...options, method: 'GET' }) + } + + async post(endpoint: string, data?: unknown, options: ApiRequestInit = {}) { + return this.request(endpoint, { + ...options, + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }) + } + + async patch(endpoint: string, data?: unknown, options: ApiRequestInit = {}) { + return this.request(endpoint, { + ...options, + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined, + }) + } + + async put(endpoint: string, data?: unknown, options: ApiRequestInit = {}) { + return this.request(endpoint, { + ...options, + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }) + } + + async delete(endpoint: string, options: ApiRequestInit = {}) { + return this.request(endpoint, { ...options, method: 'DELETE' }) + } +} + +// Export singleton instance +export const apiService = new ApiService() \ No newline at end of file diff --git a/src/services/auth.ts b/src/services/auth.ts index 1998e8f..e45bd86 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -31,17 +31,16 @@ interface ErrorResponse { error: string } +import { apiService } from './api' + const API_BASE = 'http://localhost:5000/api' class AuthService { async register(email: string, password: string, name: string): Promise { - const response = await fetch(`${API_BASE}/auth/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ email, password, name }), + const response = await apiService.post('/api/auth/register', { + email, + password, + name, }) const data = await response.json() @@ -54,13 +53,9 @@ class AuthService { } async login(email: string, password: string): Promise { - const response = await fetch(`${API_BASE}/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ email, password }), + const response = await apiService.post('/api/auth/login', { + email, + password, }) const data = await response.json() @@ -73,9 +68,7 @@ class AuthService { } async logout(): Promise { - const response = await fetch(`${API_BASE}/auth/logout`, { - credentials: 'include', - }) + const response = await apiService.get('/api/auth/logout') if (!response.ok) { throw new Error('Logout failed') @@ -84,9 +77,7 @@ class AuthService { async getCurrentUser(): Promise { try { - const response = await fetch(`${API_BASE}/auth/me`, { - credentials: 'include', - }) + const response = await apiService.get('/api/auth/me') if (!response.ok) { return null @@ -105,7 +96,7 @@ class AuthService { { name: string; display_name: string } > | null> { try { - const response = await fetch(`${API_BASE}/auth/providers`) + const response = await apiService.get('/api/auth/providers') if (!response.ok) { throw new Error('Failed to get OAuth providers') @@ -124,10 +115,7 @@ class AuthService { } async refreshToken(): Promise { - const response = await fetch(`${API_BASE}/auth/refresh`, { - method: 'POST', - credentials: 'include', - }) + const response = await apiService.post('/api/auth/refresh') if (!response.ok) { throw new Error('Token refresh failed')