Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 15 additions & 21 deletions client/src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
register: (email: string, name: string, password: string) => Promise<boolean>
logout: () => void
Expand All @@ -13,46 +19,34 @@
const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(() => {
const stored = localStorage.getItem('user')
try {
return stored ? JSON.parse(stored) : null
} catch {
localStorage.removeItem('user')
return null
}
})
const [user, setUser] = useState<AuthUser | null>(() => 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<boolean> => {
const { data } = await api.post('/auth/register', { email, name, password })
// 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 <AuthContext.Provider value={{ user, login, register, logout }}>{children}</AuthContext.Provider>
}

export function useAuth() {

Check warning on line 49 in client/src/hooks/useAuth.tsx

View workflow job for this annotation

GitHub Actions / Playwright E2E

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
Expand Down
9 changes: 6 additions & 3 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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
})
Expand All @@ -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)
}
Expand Down
73 changes: 73 additions & 0 deletions client/src/lib/authSession.ts
Original file line number Diff line number Diff line change
@@ -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<AuthSessionChangedDetail>(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<AuthSessionChangedDetail>).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)
}
}
64 changes: 64 additions & 0 deletions client/src/test/authSession.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>{user ? user.email : 'logged-out'}</div>
}

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(
<AuthProvider>
<AuthState />
</AuthProvider>,
)

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()
})
})
Loading