Files
pulse/lib/auth/context.tsx
Usman Baig 205cdf314c perf: bound SWR cache, clean stale storage, cap annotations
Add LRU cache provider (200 entries) to prevent unbounded SWR memory
growth. Clean up stale PKCE localStorage keys on app init. Cap chart
annotations to 20 visible reference lines with overflow indicator.
2026-03-10 21:19:33 +01:00

273 lines
9.0 KiB
TypeScript

'use client'
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import apiRequest from '@/lib/api/client'
import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger'
import { cleanupStaleStorage } from '@/lib/utils/storage-cleanup'
interface User {
id: string
email: string
display_name?: string
totp_enabled: boolean
org_id?: string
role?: string
preferences?: {
email_notifications?: {
new_file_received: boolean
file_downloaded: boolean
login_alerts: boolean
password_alerts: boolean
two_factor_alerts: boolean
}
}
}
interface AuthContextType {
user: User | null
loading: boolean
login: (user: User) => void
logout: () => void
refresh: () => Promise<void>
refreshSession: () => Promise<void>
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: () => {},
logout: () => {},
refresh: async () => {},
refreshSession: async () => {},
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [isLoggingOut, setIsLoggingOut] = useState(false)
const router = useRouter()
const pathname = usePathname()
const refreshToken = useCallback(async (): Promise<boolean> => {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
}
return res.ok
} catch {
return false
}
}, [])
const login = (userData: User) => {
// * We still store user profile in localStorage for optimistic UI, but NOT the token
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
setUser(userData)
router.refresh()
// * Fetch full profile (including display_name) so header shows correct name without page refresh
apiRequest<User>('/auth/user/me')
.then((fullProfile) => {
setUser((prev) => {
const merged = {
...fullProfile,
org_id: prev?.org_id ?? fullProfile.org_id,
role: prev?.role ?? fullProfile.role,
}
localStorage.setItem('user', JSON.stringify(merged))
return merged
})
})
.catch((e) => logger.error('Failed to fetch full profile after login', e))
}
const logout = useCallback(async () => {
setIsLoggingOut(true)
try { await logoutAction() } catch { /* stale build — continue with client-side cleanup */ }
localStorage.removeItem('user')
localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('ciphera_last_activity')
// * Broadcast logout to other tabs (BroadcastChannel will handle if available)
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
const channel = new BroadcastChannel('ciphera_session')
channel.postMessage({ type: 'LOGOUT' })
channel.close()
}
setTimeout(() => {
window.location.href = '/'
}, 500)
}, [])
const refresh = useCallback(async () => {
try {
const userData = await apiRequest<User>('/auth/user/me')
setUser(prev => {
const merged = {
...userData,
org_id: prev?.org_id,
role: prev?.role
}
localStorage.setItem('user', JSON.stringify(merged))
return merged
})
} catch (e) {
logger.error('Failed to refresh user data', e)
}
router.refresh()
}, [router])
const refreshSession = useCallback(async () => {
await refresh()
}, [refresh])
// Initial load
useEffect(() => {
const init = async () => {
cleanupStaleStorage()
// * 1. Check server-side session (cookies)
let session: Awaited<ReturnType<typeof getSessionAction>> = null
try {
session = await getSessionAction()
} catch {
// * Stale build — treat as no session. The login page will redirect
// * to the auth service via window.location.href (full navigation),
// * which fetches fresh HTML/JS from the server on return.
}
// * 2. If no access_token but refresh_token may exist, try refresh (fixes 15-min inactivity logout)
if (!session && typeof window !== 'undefined') {
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
if (refreshRes.ok) {
try {
session = await getSessionAction()
} catch {
// * Stale build — fall through as no session
}
}
}
if (session) {
setUser(session)
localStorage.setItem('user', JSON.stringify(session))
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
// * Fetch full profile (including display_name) from API; preserve org_id/role from session
try {
const userData = await apiRequest<User>('/auth/user/me')
const merged = { ...userData, org_id: session.org_id, role: session.role }
setUser(merged)
localStorage.setItem('user', JSON.stringify(merged))
} catch (e) {
logger.error('Failed to fetch full profile', e)
}
} else {
// * Session invalid/expired
localStorage.removeItem('user')
setUser(null)
}
setLoading(false)
}
init()
}, [])
// * Sync session across browser tabs using BroadcastChannel
useSessionSync({
onLogout: () => {
localStorage.removeItem('user')
localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('ciphera_last_activity')
window.location.href = '/'
},
onLogin: (userData) => {
setUser(userData as User)
router.refresh()
},
onRefresh: () => {
refresh()
},
})
// * Stable primitives for the effect dependency array — avoids re-running
// * on every render when the `user` object reference changes.
const isAuthenticated = !!user
const userOrgId = user?.org_id
// * Organization Wall & Auto-Switch
useEffect(() => {
const checkOrg = async () => {
if (!loading && isAuthenticated) {
if (pathname?.startsWith('/onboarding')) return
if (pathname?.startsWith('/auth/callback')) return
try {
const organizations = await getUserOrganizations()
if (organizations.length === 0) {
if (pathname?.startsWith('/welcome')) return
router.push('/welcome')
return
}
// * If user has organizations but no context (org_id), switch to the first one
if (!userOrgId && organizations.length > 0) {
const firstOrg = organizations[0]
try {
const { access_token } = await switchContext(firstOrg.organization_id)
// * Update session cookie
const result = await setSessionAction(access_token)
if (result.success && result.user) {
try {
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
setUser(merged)
localStorage.setItem('user', JSON.stringify(merged))
} catch {
setUser(result.user)
localStorage.setItem('user', JSON.stringify(result.user))
}
router.refresh()
}
} catch (e) {
logger.error('Failed to auto-switch context', e)
}
}
} catch (e) {
logger.error("Failed to fetch organizations", e)
}
}
}
checkOrg()
}, [loading, isAuthenticated, userOrgId, pathname, router])
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
{isLoggingOut && <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />}
<SessionExpiryWarning
isAuthenticated={!!user}
onRefreshToken={refreshToken}
onExpired={logout}
/>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)