fix: Comprehensive authentication and UI fixes
Authentication & Token Management: - Add deviceId to token refresh flow (backend requires both refreshToken and deviceId) - Fix React Strict Mode token clearing race condition with retry logic - Improve AuthContext to handle all token state combinations properly - Store deviceId in localStorage alongside tokens UI/UX Improvements: - Remove deprecated legacyBehavior from Next.js Link components - Update primary theme color to WCAG AA compliant #7c3aed - Fix nested button error in TabBar voice navigation - Fix invalid Tabs value error in DynamicChildDashboard Multi-Child Dashboard: - Load all children into Redux store properly - Fetch metrics for all children, not just selected one - Remove mock data to prevent unauthorized API calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -42,19 +42,35 @@ apiClient.interceptors.response.use(
|
||||
|
||||
try {
|
||||
const refreshToken = tokenStorage.getRefreshToken();
|
||||
const deviceId = tokenStorage.getDeviceId();
|
||||
|
||||
console.log('[API Client] Attempting token refresh, refreshToken exists:', !!refreshToken, 'deviceId exists:', !!deviceId);
|
||||
|
||||
if (!refreshToken) {
|
||||
console.error('[API Client] No refresh token found in storage');
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
if (!deviceId) {
|
||||
console.error('[API Client] No device ID found in storage');
|
||||
throw new Error('No device ID');
|
||||
}
|
||||
|
||||
// Use a plain axios instance without interceptors to avoid loops
|
||||
const refreshResponse = await axios.create().post(
|
||||
`${API_BASE_URL}/api/v1/auth/refresh`,
|
||||
{ refreshToken },
|
||||
{
|
||||
refreshToken,
|
||||
deviceId
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true
|
||||
}
|
||||
);
|
||||
|
||||
const response = refreshResponse;
|
||||
|
||||
// Handle different response structures
|
||||
let newAccessToken;
|
||||
let newRefreshToken;
|
||||
@@ -81,13 +97,28 @@ apiClient.interceptors.response.use(
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
tokenStorage.clearTokens();
|
||||
} catch (refreshError: any) {
|
||||
console.error('[API Client] Token refresh failed:', refreshError);
|
||||
|
||||
// Only clear tokens if this is a real auth failure (not a network error)
|
||||
// and not during the initial page load where React Strict Mode might cause issues
|
||||
const isAuthFailure = refreshError?.response?.status === 401 ||
|
||||
refreshError?.response?.status === 403;
|
||||
|
||||
// Check if this is likely a React Strict Mode double-invocation
|
||||
// by seeing if we're in development mode and the error happened very quickly
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
if (isAuthFailure && !isDevelopment) {
|
||||
console.log('[API Client] Auth failure in production, clearing tokens');
|
||||
tokenStorage.clearTokens();
|
||||
} else if (isDevelopment) {
|
||||
console.log('[API Client] Development mode - not clearing tokens to avoid React Strict Mode issues');
|
||||
}
|
||||
|
||||
// Avoid redirect loop - only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
// and only in production or after a real auth failure
|
||||
if (!window.location.pathname.includes('/login') && isAuthFailure && !isDevelopment) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
|
||||
@@ -76,13 +76,58 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
try {
|
||||
const accessToken = tokenStorage.getAccessToken();
|
||||
if (!accessToken) {
|
||||
const refreshToken = tokenStorage.getRefreshToken();
|
||||
|
||||
console.log('[AuthContext] checkAuth - tokens present:', {
|
||||
hasAccess: !!accessToken,
|
||||
hasRefresh: !!refreshToken
|
||||
});
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
console.log('[AuthContext] No tokens found, user not authenticated');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set token in state
|
||||
setToken(accessToken);
|
||||
// If we only have refresh token but no access token, don't make /me call
|
||||
// The axios interceptor will handle getting a new access token when needed
|
||||
if (!accessToken && refreshToken) {
|
||||
console.log('[AuthContext] Only refresh token present, skipping /me call');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we only have access token but no refresh token, it might be a timing issue
|
||||
// during login (React Strict Mode). Give it a moment and check again.
|
||||
if (accessToken && !refreshToken) {
|
||||
console.log('[AuthContext] Only access token present, checking if this is temporary...');
|
||||
|
||||
// Wait a tiny bit and check again (for React Strict Mode race condition)
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const refreshTokenRetry = tokenStorage.getRefreshToken();
|
||||
|
||||
if (!refreshTokenRetry) {
|
||||
console.log('[AuthContext] Still no refresh token after retry, clearing invalid state');
|
||||
tokenStorage.clearTokens();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} else {
|
||||
console.log('[AuthContext] Refresh token found on retry, proceeding with auth check');
|
||||
// Update local variable for the rest of the function
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we have both tokens - proceed with auth check
|
||||
// Set token in state if we have one
|
||||
if (accessToken) {
|
||||
setToken(accessToken);
|
||||
}
|
||||
|
||||
const response = await apiClient.get('/api/v1/auth/me');
|
||||
|
||||
@@ -104,9 +149,28 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
throw new Error('Invalid response structure');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Auth check failed:', error);
|
||||
// Only clear tokens if it's an actual auth error (401, 403)
|
||||
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
||||
console.error('[AuthContext] Auth check failed:', error);
|
||||
// Don't clear tokens on 401 during initial auth check
|
||||
// The axios interceptor in client.ts will handle token refresh automatically
|
||||
// Only clear tokens if the error is NOT a 401 (e.g., network error, 403, etc.)
|
||||
// Or if there's no refresh token available (meaning refresh already failed)
|
||||
const hasRefreshToken = tokenStorage.getRefreshToken();
|
||||
|
||||
if (!hasRefreshToken) {
|
||||
// No refresh token means we can't recover - clear everything
|
||||
console.log('[AuthContext] No refresh token available, clearing auth state');
|
||||
tokenStorage.clearTokens();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} else if (error?.response?.status === 401) {
|
||||
// 401 with refresh token - let axios interceptor handle refresh
|
||||
console.log('[AuthContext] 401 error but refresh token exists, letting axios interceptor handle refresh');
|
||||
// Don't clear tokens - the axios interceptor will attempt refresh
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} else if (error?.response?.status === 403) {
|
||||
// 403 means forbidden - clear tokens
|
||||
console.log('[AuthContext] 403 Forbidden, clearing auth state');
|
||||
tokenStorage.clearTokens();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
@@ -118,8 +182,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const login = async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
const deviceId = generateDeviceFingerprint();
|
||||
const deviceInfo = {
|
||||
deviceId: generateDeviceFingerprint(),
|
||||
deviceId,
|
||||
platform: 'web',
|
||||
model: navigator.userAgent,
|
||||
osVersion: navigator.platform,
|
||||
@@ -141,7 +206,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
eulaVersion: userData.eulaVersion,
|
||||
});
|
||||
|
||||
tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken);
|
||||
// Store tokens and deviceId
|
||||
tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken, deviceId);
|
||||
setToken(tokens.accessToken);
|
||||
setUser(userData);
|
||||
|
||||
@@ -154,8 +220,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const register = async (data: RegisterData) => {
|
||||
try {
|
||||
const deviceId = generateDeviceFingerprint();
|
||||
const deviceInfo = {
|
||||
deviceId: generateDeviceFingerprint(),
|
||||
deviceId,
|
||||
platform: 'web',
|
||||
model: navigator.userAgent,
|
||||
osVersion: navigator.platform,
|
||||
@@ -205,7 +272,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
}] : [],
|
||||
};
|
||||
|
||||
tokenStorage.setTokens(accessToken, refreshToken);
|
||||
tokenStorage.setTokens(accessToken, refreshToken, deviceId);
|
||||
setToken(accessToken);
|
||||
setUser(userWithFamily);
|
||||
|
||||
|
||||
@@ -61,12 +61,44 @@ export const tokenStorage = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get device ID from storage
|
||||
*/
|
||||
getDeviceId: (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem('deviceId');
|
||||
} catch (error) {
|
||||
console.error('Error reading deviceId:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set device ID in storage
|
||||
*/
|
||||
setDeviceId: (deviceId: string): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('deviceId', deviceId);
|
||||
} catch (error) {
|
||||
console.error('Error setting deviceId:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set both tokens at once
|
||||
*/
|
||||
setTokens: (accessToken: string, refreshToken: string): void => {
|
||||
setTokens: (accessToken: string, refreshToken: string, deviceId?: string): void => {
|
||||
tokenStorage.setAccessToken(accessToken);
|
||||
tokenStorage.setRefreshToken(refreshToken);
|
||||
if (deviceId) {
|
||||
tokenStorage.setDeviceId(deviceId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -79,6 +111,7 @@ export const tokenStorage = {
|
||||
try {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('deviceId');
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user