// vibe_erp web auth context. // // **Scope.** Holds the in-memory copy of the access token + the user // it was issued to (decoded from the JWT payload), exposes login / // logout actions, and rerenders subscribers when the token state // flips. Persistence lives in localStorage via the api client's // `getAccessToken/setAccessToken` helpers — the context only mirrors // what's in storage so a hard refresh of the page picks up the // existing session without a re-login. // // **Why decode the JWT in the SPA.** vibe_erp's auth tokens are // signed (HS256) by the framework, so the SPA cannot *trust* the // payload — but it can DISPLAY it (username, role list) without a // round-trip. Anything load-bearing (permission checks) still // happens server-side. The decode is intentionally trivial: split // on '.', base64-decode the middle segment, JSON.parse. No JWT lib // dependency; an invalid token just degrades to "unknown user" and // the next API call gets a 401, triggering a logout. // // **401 handling.** The api client calls registerUnauthorizedHandler // once at boot. When any request returns 401, the handler clears // the token and forces a navigate to /login (preserving the // attempted path so post-login can redirect back). import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { auth as authApi, getAccessToken, registerUnauthorizedHandler, setAccessToken, } from '@/api/client' interface JwtPayload { sub?: string username?: string roles?: string[] exp?: number } function decodeJwt(token: string): JwtPayload | null { try { const parts = token.split('.') if (parts.length !== 3) return null const padded = parts[1] + '==='.slice((parts[1].length + 3) % 4) const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/')) return JSON.parse(json) as JwtPayload } catch { return null } } interface AuthState { token: string | null username: string | null roles: string[] loading: boolean login: (username: string, password: string) => Promise logout: () => void } const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const navigate = useNavigate() const location = useLocation() const [token, setToken] = useState(() => getAccessToken()) const [loading, setLoading] = useState(false) const payload = useMemo(() => (token ? decodeJwt(token) : null), [token]) const logout = useCallback(() => { setAccessToken(null) setToken(null) if (location.pathname !== '/login') { navigate('/login', { replace: true, state: { from: location.pathname + location.search }, }) } }, [navigate, location]) // Wire the api client's 401 handler exactly once. useEffect(() => { registerUnauthorizedHandler(() => { // Defer to next tick so the throw inside apiFetch isn't // swallowed by React's render-phase complaints. setTimeout(() => logout(), 0) }) }, [logout]) const login = useCallback( async (username: string, password: string) => { setLoading(true) try { const pair = await authApi.login(username, password) setAccessToken(pair.accessToken) setToken(pair.accessToken) } finally { setLoading(false) } }, [], ) const value: AuthState = { token, username: payload?.username ?? payload?.sub ?? null, roles: payload?.roles ?? [], loading, login, logout, } return {children} } export function useAuth(): AuthState { const ctx = useContext(AuthContext) if (!ctx) throw new Error('useAuth must be used inside ') return ctx }