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:
@@ -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>
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
150
src/services/api.ts
Normal 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()
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user