From b48aaded0587638cb42e57285f091c788a25d52b Mon Sep 17 00:00:00 2001 From: andupetcu <47487320+andupetcu@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:57:37 +0300 Subject: [PATCH] Fix session persistence issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created SSR-safe tokenStorage utility for localStorage access - Updated AuthContext with window availability checks - Enhanced API client interceptors with SSR safety - Improved error handling to only clear tokens on auth errors (401/403) - Added token refresh support for multiple response structures - Added redirect loop prevention in auth flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- maternal-web/lib/api/client.ts | 57 +++++++++++++--- maternal-web/lib/auth/AuthContext.tsx | 47 +++++++++---- maternal-web/lib/utils/tokenStorage.ts | 93 ++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 maternal-web/lib/utils/tokenStorage.ts diff --git a/maternal-web/lib/api/client.ts b/maternal-web/lib/api/client.ts index 7a40970..022d75a 100644 --- a/maternal-web/lib/api/client.ts +++ b/maternal-web/lib/api/client.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { tokenStorage } from '@/lib/utils/tokenStorage'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; @@ -13,7 +14,7 @@ export const apiClient = axios.create({ // Request interceptor to add auth token apiClient.interceptors.request.use( (config) => { - const token = localStorage.getItem('accessToken'); + const token = tokenStorage.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -30,31 +31,65 @@ apiClient.interceptors.response.use( async (error) => { const originalRequest = error.config; + // Only handle token refresh on client side + if (typeof window === 'undefined') { + return Promise.reject(error); + } + // If error is 401 and we haven't tried to refresh yet if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { - const refreshToken = localStorage.getItem('refreshToken'); + const refreshToken = tokenStorage.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token'); } - const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, { - refreshToken, - }); + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/refresh`, + { refreshToken }, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true + } + ); - const { accessToken } = response.data; - localStorage.setItem('accessToken', accessToken); + // Handle different response structures + let newAccessToken; + let newRefreshToken; + + if (response.data?.data?.tokens?.accessToken) { + newAccessToken = response.data.data.tokens.accessToken; + newRefreshToken = response.data.data.tokens.refreshToken; + } else if (response.data?.tokens?.accessToken) { + newAccessToken = response.data.tokens.accessToken; + newRefreshToken = response.data.tokens.refreshToken; + } else if (response.data?.accessToken) { + newAccessToken = response.data.accessToken; + newRefreshToken = response.data.refreshToken; + } else { + throw new Error('Invalid token refresh response'); + } + + // Update tokens in storage + tokenStorage.setAccessToken(newAccessToken); + if (newRefreshToken) { + tokenStorage.setRefreshToken(newRefreshToken); + } // Retry original request with new token - originalRequest.headers.Authorization = `Bearer ${accessToken}`; + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return apiClient(originalRequest); } catch (refreshError) { + console.error('Token refresh failed:', refreshError); // Refresh failed, clear tokens and redirect to login - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - window.location.href = '/login'; + tokenStorage.clearTokens(); + + // Avoid redirect loop - only redirect if not already on login page + if (!window.location.pathname.includes('/login')) { + window.location.href = '/login'; + } return Promise.reject(refreshError); } } diff --git a/maternal-web/lib/auth/AuthContext.tsx b/maternal-web/lib/auth/AuthContext.tsx index a9774ff..1b597b4 100644 --- a/maternal-web/lib/auth/AuthContext.tsx +++ b/maternal-web/lib/auth/AuthContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { useRouter } from 'next/navigation'; import apiClient from '@/lib/api/client'; +import { tokenStorage } from '@/lib/utils/tokenStorage'; export interface User { id: string; @@ -45,23 +46,46 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // Check authentication status on mount useEffect(() => { - checkAuth(); + // Only run on client side + if (typeof window !== 'undefined') { + checkAuth(); + } else { + setIsLoading(false); + } }, []); const checkAuth = async () => { - const token = localStorage.getItem('accessToken'); - if (!token) { + // Ensure we're on client side + if (typeof window === 'undefined') { setIsLoading(false); return; } try { + const token = tokenStorage.getAccessToken(); + if (!token) { + setIsLoading(false); + return; + } + const response = await apiClient.get('/api/v1/auth/me'); - setUser(response.data.data); - } catch (error) { + + // Check if response has expected structure + if (response.data?.data) { + setUser(response.data.data); + } else if (response.data?.user) { + // Handle alternative response structure + setUser(response.data.user); + } else { + throw new Error('Invalid response structure'); + } + } catch (error: any) { console.error('Auth check failed:', error); - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); + // Only clear tokens if it's an actual auth error (401, 403) + if (error?.response?.status === 401 || error?.response?.status === 403) { + tokenStorage.clearTokens(); + setUser(null); + } } finally { setIsLoading(false); } @@ -86,8 +110,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { data: responseData } = response.data; const { tokens, user: userData } = responseData; - localStorage.setItem('accessToken', tokens.accessToken); - localStorage.setItem('refreshToken', tokens.refreshToken); + tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken); setUser(userData); router.push('/'); @@ -123,8 +146,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { accessToken, refreshToken } = tokens; - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', refreshToken); + tokenStorage.setTokens(accessToken, refreshToken); setUser(userData); // Redirect to onboarding @@ -141,8 +163,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } catch (error) { console.error('Logout failed:', error); } finally { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); + tokenStorage.clearTokens(); setUser(null); router.push('/login'); } diff --git a/maternal-web/lib/utils/tokenStorage.ts b/maternal-web/lib/utils/tokenStorage.ts new file mode 100644 index 0000000..5a23e71 --- /dev/null +++ b/maternal-web/lib/utils/tokenStorage.ts @@ -0,0 +1,93 @@ +/** + * Safe token storage utilities that work with both SSR and client-side rendering + */ + +export const tokenStorage = { + /** + * Get access token from storage + */ + getAccessToken: (): string | null => { + if (typeof window === 'undefined') { + return null; + } + try { + return localStorage.getItem('accessToken'); + } catch (error) { + console.error('Error reading accessToken:', error); + return null; + } + }, + + /** + * Get refresh token from storage + */ + getRefreshToken: (): string | null => { + if (typeof window === 'undefined') { + return null; + } + try { + return localStorage.getItem('refreshToken'); + } catch (error) { + console.error('Error reading refreshToken:', error); + return null; + } + }, + + /** + * Set access token in storage + */ + setAccessToken: (token: string): void => { + if (typeof window === 'undefined') { + return; + } + try { + localStorage.setItem('accessToken', token); + } catch (error) { + console.error('Error setting accessToken:', error); + } + }, + + /** + * Set refresh token in storage + */ + setRefreshToken: (token: string): void => { + if (typeof window === 'undefined') { + return; + } + try { + localStorage.setItem('refreshToken', token); + } catch (error) { + console.error('Error setting refreshToken:', error); + } + }, + + /** + * Set both tokens at once + */ + setTokens: (accessToken: string, refreshToken: string): void => { + tokenStorage.setAccessToken(accessToken); + tokenStorage.setRefreshToken(refreshToken); + }, + + /** + * Clear all tokens from storage + */ + clearTokens: (): void => { + if (typeof window === 'undefined') { + return; + } + try { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } catch (error) { + console.error('Error clearing tokens:', error); + } + }, + + /** + * Check if user has valid tokens + */ + hasTokens: (): boolean => { + return !!(tokenStorage.getAccessToken() && tokenStorage.getRefreshToken()); + }, +};