📦 EqualifyEverything / equalify-reflow

📄 AuthContext.tsx · 115 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'

import { apiFetch } from './apiFetch'
import type { AuthConfig, Identity } from './types'

interface AuthContextValue {
  // Loading: initial /auth/config fetch in flight.
  loading: boolean
  config: AuthConfig | null
  identity: Identity | null
  refreshIdentity: () => Promise<void>
  // Mark identity present after a successful login from the LoginPage.
  setIdentity: (identity: Identity | null) => void
}

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

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

export function AuthProvider({ children }: { children: ReactNode }) {
  const [loading, setLoading] = useState(true)
  const [config, setConfig] = useState<AuthConfig | null>(null)
  const [identity, setIdentity] = useState<Identity | null>(null)

  const refreshIdentity = async () => {
    if (!config || config.mode === 'none') {
      setIdentity(null)
      return
    }
    try {
      const res = await apiFetch('/api/v1/auth/me')
      if (res.ok) {
        setIdentity((await res.json()) as Identity)
      } else {
        setIdentity(null)
      }
    } catch {
      setIdentity(null)
    }
  }

  // Fetch /auth/config on mount; if mode != none, also fetch /me to discover
  // whether the browser already has a valid session.
  useEffect(() => {
    let cancelled = false
    const init = async () => {
      try {
        const res = await apiFetch('/api/v1/auth/config')
        if (cancelled) return
        if (!res.ok) {
          // Treat unreachable config as 'none' — never block the SPA from
          // rendering when the auth subsystem is mis-deployed.
          setConfig({ mode: 'none', providers: [] })
          return
        }
        const cfg = (await res.json()) as AuthConfig
        setConfig(cfg)
        if (cfg.mode !== 'none') {
          const meRes = await apiFetch('/api/v1/auth/me')
          if (!cancelled && meRes.ok) {
            setIdentity((await meRes.json()) as Identity)
          }
        }
      } finally {
        if (!cancelled) setLoading(false)
      }
    }
    void init()
    return () => {
      cancelled = true
    }
  }, [])

  return (
    <AuthContext.Provider
      value={{ loading, config, identity, refreshIdentity, setIdentity }}
    >
      {children}
    </AuthContext.Provider>
  )
}

// Wrapper that gates a route on either AUTH_MODE=none (open) or a present
// identity. Renders children when allowed; otherwise redirects to /login.
export function RequireAuth({ children }: { children: ReactNode }) {
  const { loading, config, identity } = useAuth()
  const location = useLocation()

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
        Loading…
      </div>
    )
  }

  if (!config || config.mode === 'none') {
    return <>{children}</>
  }

  if (identity === null) {
    const next = encodeURIComponent(location.pathname + location.search)
    return <Navigate to={`/login?next=${next}`} replace />
  }

  return <>{children}</>
}