diff --git a/client/src/hooks/useAuth.tsx b/client/src/hooks/useAuth.tsx index a62b131f..2d3fd0bb 100644 --- a/client/src/hooks/useAuth.tsx +++ b/client/src/hooks/useAuth.tsx @@ -1,10 +1,16 @@ -import { createContext, useContext, useState } from 'react' +import { createContext, useContext, useEffect, useState } from 'react' import type { ReactNode } from 'react' import { api } from '../lib/api' +import { + clearAuthSession, + getStoredUser, + setAuthSession, + subscribeToAuthSession, + type AuthUser, +} from '../lib/authSession' -interface User { id: string; email: string; name: string } interface AuthContextType { - user: User | null + user: AuthUser | null login: (email: string, password: string) => Promise register: (email: string, name: string, password: string) => Promise logout: () => void @@ -13,21 +19,13 @@ interface AuthContextType { const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(() => { - const stored = localStorage.getItem('user') - try { - return stored ? JSON.parse(stored) : null - } catch { - localStorage.removeItem('user') - return null - } - }) + const [user, setUser] = useState(() => getStoredUser()) + + useEffect(() => subscribeToAuthSession(setUser), []) const login = async (email: string, password: string) => { const { data } = await api.post('/auth/login', { email, password }) - localStorage.setItem('token', data.token) - localStorage.setItem('user', JSON.stringify(data.user)) - setUser(data.user) + setAuthSession(data.token, data.user) } const register = async (email: string, name: string, password: string): Promise => { @@ -35,18 +33,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Server returns no token for existing emails (enumeration prevention). // Only log the user in when a real token is returned (HTTP 201). if (data.token) { - localStorage.setItem('token', data.token) - localStorage.setItem('user', JSON.stringify(data.user)) - setUser(data.user) + setAuthSession(data.token, data.user) return true // navigable — user is now logged in } return false // existing email — show generic success, don't navigate } const logout = () => { - localStorage.removeItem('token') - localStorage.removeItem('user') - setUser(null) + clearAuthSession() } return {children} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 572a9cb0..9e2c8220 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { clearAuthSession, getStoredToken } from './authSession' const baseURL = import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api` @@ -7,7 +8,7 @@ const baseURL = import.meta.env.VITE_API_URL export const api = axios.create({ baseURL, timeout: 30000 }) api.interceptors.request.use((config) => { - const token = localStorage.getItem('token') + const token = getStoredToken() if (token) config.headers.Authorization = `Bearer ${token}` return config }) @@ -17,8 +18,10 @@ api.interceptors.response.use( (err) => { const isAuthRoute = err.config?.url?.startsWith('/auth/') if (err.response?.status === 401 && !isAuthRoute) { - localStorage.removeItem('token') - window.location.href = '/login' + clearAuthSession() + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } return Promise.reject(err) } diff --git a/client/src/lib/authSession.ts b/client/src/lib/authSession.ts new file mode 100644 index 00000000..c55667d6 --- /dev/null +++ b/client/src/lib/authSession.ts @@ -0,0 +1,73 @@ +export interface AuthUser { + id: string + email: string + name: string +} + +const TOKEN_STORAGE_KEY = 'token' +const USER_STORAGE_KEY = 'user' +const AUTH_SESSION_CHANGED_EVENT = 'auth:session-changed' + +function dispatchAuthSessionChanged(user: AuthUser | null) { + if (typeof window === 'undefined') return + + window.dispatchEvent(new CustomEvent(AUTH_SESSION_CHANGED_EVENT, { + detail: { user }, + })) +} + +export function getStoredToken() { + return localStorage.getItem(TOKEN_STORAGE_KEY) +} + +export function getStoredUser(): AuthUser | null { + const storedUser = localStorage.getItem(USER_STORAGE_KEY) + + if (!storedUser) return null + + try { + return JSON.parse(storedUser) as AuthUser + } catch { + clearAuthSession() + return null + } +} + +export function setAuthSession(token: string, user: AuthUser) { + localStorage.setItem(TOKEN_STORAGE_KEY, token) + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)) + dispatchAuthSessionChanged(user) +} + +export function clearAuthSession() { + localStorage.removeItem(TOKEN_STORAGE_KEY) + localStorage.removeItem(USER_STORAGE_KEY) + dispatchAuthSessionChanged(null) +} + +interface AuthSessionChangedDetail { + user: AuthUser | null +} + +export function subscribeToAuthSession(callback: (user: AuthUser | null) => void) { + if (typeof window === 'undefined') return () => {} + + const handleSessionChanged = (event: Event) => { + const detail = (event as CustomEvent).detail + callback(detail?.user ?? getStoredUser()) + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === null || event.key === TOKEN_STORAGE_KEY || event.key === USER_STORAGE_KEY) { + callback(getStoredUser()) + } + } + + window.addEventListener(AUTH_SESSION_CHANGED_EVENT, handleSessionChanged as EventListener) + window.addEventListener('storage', handleStorage) + + return () => { + window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, handleSessionChanged as EventListener) + window.removeEventListener('storage', handleStorage) + } +} diff --git a/client/src/test/authSession.test.tsx b/client/src/test/authSession.test.tsx new file mode 100644 index 00000000..b06651ff --- /dev/null +++ b/client/src/test/authSession.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, beforeEach } from 'vitest' +import { AuthProvider, useAuth } from '@/hooks/useAuth' +import { clearAuthSession, getStoredUser, setAuthSession } from '@/lib/authSession' + +function AuthState() { + const { user } = useAuth() + + return
{user ? user.email : 'logged-out'}
+} + +describe('auth session', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('stores and clears token and user together', () => { + const user = { id: 'user-1', email: 'alex@example.com', name: 'Alex' } + + setAuthSession('token-123', user) + + expect(localStorage.getItem('token')).toBe('token-123') + expect(localStorage.getItem('user')).toBe(JSON.stringify(user)) + expect(getStoredUser()).toEqual(user) + + act(() => { + clearAuthSession() + }) + + expect(localStorage.getItem('token')).toBeNull() + expect(localStorage.getItem('user')).toBeNull() + expect(getStoredUser()).toBeNull() + }) + + it('updates AuthProvider immediately when the session is cleared externally', async () => { + setAuthSession('token-123', { id: 'user-1', email: 'alex@example.com', name: 'Alex' }) + + render( + + + , + ) + + expect(screen.getByText('alex@example.com')).toBeInTheDocument() + + act(() => { + clearAuthSession() + }) + + await waitFor(() => { + expect(screen.getByText('logged-out')).toBeInTheDocument() + }) + }) + + it('clears an invalid stored session payload', () => { + localStorage.setItem('token', 'token-123') + localStorage.setItem('user', '{bad json') + + expect(getStoredUser()).toBeNull() + expect(localStorage.getItem('token')).toBeNull() + expect(localStorage.getItem('user')).toBeNull() + }) +})