feat: unify API requests by introducing ApiService; update AuthService and AccountPage to use the new service for API calls; standardize ThemeProvider storageKey

This commit is contained in:
JSC
2025-06-30 13:18:01 +02:00
parent e484251787
commit 5ad74d0b21
6 changed files with 184 additions and 50 deletions

View File

@@ -13,7 +13,7 @@ import { ThemeProvider } from './components/ThemeProvider'
function App() { function App() {
return ( return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="theme">
<AuthProvider> <AuthProvider>
<Router> <Router>
<Routes> <Routes>

View File

@@ -23,7 +23,7 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
storageKey = "vite-ui-theme", storageKey = "theme",
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>( const [theme, setTheme] = useState<Theme>(

View File

@@ -20,7 +20,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const currentUser = await authService.getCurrentUser() const currentUser = await authService.getCurrentUser()
setUser(currentUser) setUser(currentUser)
} catch (error) { } catch {
setUser(null) setUser(null)
} }
} }
@@ -31,7 +31,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false) 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() initAuth()
return () => {
window.removeEventListener('auth:refresh-token-expired', handleRefreshTokenExpired)
}
}, []) }, [])
// Handle OAuth redirect - only run once when page loads // Handle OAuth redirect - only run once when page loads

View File

@@ -10,6 +10,7 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { apiService } from '@/services/api'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
export function AccountPage() { export function AccountPage() {
@@ -69,13 +70,8 @@ export function AccountPage() {
setMessage('') setMessage('')
try { try {
const response = await fetch('http://localhost:5000/api/auth/profile', { const response = await apiService.patch('/api/auth/profile', {
method: 'PATCH', name: name.trim(),
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ name: name.trim() }),
}) })
const data = await response.json() const data = await response.json()
@@ -100,12 +96,8 @@ export function AccountPage() {
setMessage('') setMessage('')
try { try {
const response = await fetch( const response = await apiService.post(
'http://localhost:5000/api/auth/regenerate-api-token', '/api/auth/regenerate-api-token'
{
method: 'POST',
credentials: 'include',
},
) )
const data = await response.json() const data = await response.json()
@@ -188,19 +180,12 @@ export function AccountPage() {
setMessage('') setMessage('')
try { try {
const requestBody: any = { new_password: newPassword } const requestBody: { new_password: string; current_password?: string } = { new_password: newPassword }
if (loggedInViaPassword) { if (loggedInViaPassword) {
requestBody.current_password = currentPassword requestBody.current_password = currentPassword
} }
const response = await fetch('http://localhost:5000/api/auth/password', { const response = await apiService.put('/api/auth/password', requestBody)
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(requestBody),
})
const data = await response.json() const data = await response.json()

150
src/services/api.ts Normal file
View File

@@ -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<boolean> | null = null
/**
* Main API request method with automatic token refresh
*/
async request(endpoint: string, options: ApiRequestInit = {}): Promise<Response> {
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<boolean> {
// 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<boolean> {
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()

View File

@@ -31,17 +31,16 @@ interface ErrorResponse {
error: string error: string
} }
import { apiService } from './api'
const API_BASE = 'http://localhost:5000/api' const API_BASE = 'http://localhost:5000/api'
class AuthService { class AuthService {
async register(email: string, password: string, name: string): Promise<User> { async register(email: string, password: string, name: string): Promise<User> {
const response = await fetch(`${API_BASE}/auth/register`, { const response = await apiService.post('/api/auth/register', {
method: 'POST', email,
headers: { password,
'Content-Type': 'application/json', name,
},
credentials: 'include',
body: JSON.stringify({ email, password, name }),
}) })
const data = await response.json() const data = await response.json()
@@ -54,13 +53,9 @@ class AuthService {
} }
async login(email: string, password: string): Promise<User> { async login(email: string, password: string): Promise<User> {
const response = await fetch(`${API_BASE}/auth/login`, { const response = await apiService.post('/api/auth/login', {
method: 'POST', email,
headers: { password,
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, password }),
}) })
const data = await response.json() const data = await response.json()
@@ -73,9 +68,7 @@ class AuthService {
} }
async logout(): Promise<void> { async logout(): Promise<void> {
const response = await fetch(`${API_BASE}/auth/logout`, { const response = await apiService.get('/api/auth/logout')
credentials: 'include',
})
if (!response.ok) { if (!response.ok) {
throw new Error('Logout failed') throw new Error('Logout failed')
@@ -84,9 +77,7 @@ class AuthService {
async getCurrentUser(): Promise<User | null> { async getCurrentUser(): Promise<User | null> {
try { try {
const response = await fetch(`${API_BASE}/auth/me`, { const response = await apiService.get('/api/auth/me')
credentials: 'include',
})
if (!response.ok) { if (!response.ok) {
return null return null
@@ -105,7 +96,7 @@ class AuthService {
{ name: string; display_name: string } { name: string; display_name: string }
> | null> { > | null> {
try { try {
const response = await fetch(`${API_BASE}/auth/providers`) const response = await apiService.get('/api/auth/providers')
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to get OAuth providers') throw new Error('Failed to get OAuth providers')
@@ -124,10 +115,7 @@ class AuthService {
} }
async refreshToken(): Promise<void> { async refreshToken(): Promise<void> {
const response = await fetch(`${API_BASE}/auth/refresh`, { const response = await apiService.post('/api/auth/refresh')
method: 'POST',
credentials: 'include',
})
if (!response.ok) { if (!response.ok) {
throw new Error('Token refresh failed') throw new Error('Token refresh failed')