AuthContext.tsx 3.82 KB
// 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<void>
  logout: () => void
}

const AuthContext = createContext<AuthState | null>(null)

export function AuthProvider({ children }: { children: ReactNode }) {
  const navigate = useNavigate()
  const location = useLocation()
  const [token, setToken] = useState<string | null>(() => 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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

export function useAuth(): AuthState {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
  return ctx
}