spine-framework 0.3.90 → 0.3.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,12 +59,18 @@ export const list = createHandler(async (ctx, body) => {
59
59
  throw new Error('Account context required')
60
60
  }
61
61
 
62
- // RLS automatically filters to accessible accounts
62
+ // Pass user roles to the RPC so filtering happens server-side.
63
+ // system_admin bypasses all role checks in the RPC.
64
+ const userRoles = ctx.principal?.roles || []
65
+ const isSysAdmin = userRoles.includes('system_admin')
66
+
63
67
  const { data, error: err } = await ctx.db
64
68
  .rpc('get_account_apps', {
65
69
  account_id: targetAccountId,
66
70
  include_system: include_system !== 'false',
67
- include_inactive: include_inactive === 'true'
71
+ include_inactive: include_inactive === 'true',
72
+ user_role_slugs: userRoles,
73
+ is_system_admin: isSysAdmin,
68
74
  })
69
75
 
70
76
  if (err) throw err
@@ -1,4 +1,4 @@
1
- import React, { createContext, useContext, useState, useEffect } from 'react'
1
+ import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { AppRecord } from '../hooks/useApps'
3
3
  import { apiFetch } from '../lib/api'
4
4
  import { useAuth } from './AuthContext'
@@ -69,17 +69,23 @@ interface AppsRegistryProviderProps {
69
69
  }
70
70
 
71
71
  /**
72
- * Fetches all accessible apps once after authentication and provides them
73
- * globally. Wrap this around AuthenticatedRouter so routes don't re-fetch
74
- * on every navigation.
72
+ * Fetches accessible apps for the current user and provides them globally.
73
+ *
74
+ * Role-based filtering is done server-side by the `get_account_apps` RPC
75
+ * (migration 020). The client trusts the backend response and only filters
76
+ * for routability (has a route_prefix and a renderer).
77
+ *
78
+ * Uses an AbortController to cancel in-flight fetches when dependencies
79
+ * change, preventing interleaved responses from stale requests.
75
80
  */
76
81
  export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
77
82
  const { user } = useAuth()
78
83
  const [apps, setApps] = useState<AppRecord[]>([])
79
84
  const [loading, setLoading] = useState(true)
80
85
  const [error, setError] = useState<string | null>(null)
86
+ const abortRef = useRef<AbortController | null>(null)
81
87
 
82
- const fetchApps = async () => {
88
+ const fetchApps = useCallback(async (signal?: AbortSignal) => {
83
89
  if (!user) {
84
90
  setApps([])
85
91
  setLoading(false)
@@ -90,44 +96,54 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
90
96
  setLoading(true)
91
97
  setError(null)
92
98
 
93
- const response = await apiFetch('/api/apps?action=list')
99
+ const response = await apiFetch('/api/apps?action=list', { signal })
100
+
101
+ if (signal?.aborted) return
102
+
94
103
  if (!response.ok) throw new Error(`Failed to fetch apps: ${response.statusText}`)
95
104
 
96
105
  const data = await response.json()
97
106
  if (data.error) throw new Error(data.error)
98
107
 
108
+ // Backend already filters by role — no client-side role check needed
99
109
  const allApps: AppRecord[] = data.data || data || []
100
-
101
- const accessible = allApps.filter(app => {
102
- if (!app.is_active) return false
103
- const requiredRoles = app.required_roles || (app.min_role ? [app.min_role] : [])
104
- if (requiredRoles.length === 0) return true
105
- if (!user.roles || user.roles.length === 0) return false
106
- if (user.roles.includes('system_admin')) return true
107
- return requiredRoles.some(role => user.roles!.includes(role))
108
- })
109
-
110
- setApps(accessible)
110
+ setApps(allApps)
111
111
  } catch (err) {
112
+ if (err instanceof DOMException && err.name === 'AbortError') return
112
113
  setError(err instanceof Error ? err.message : 'Failed to load apps')
113
114
  setApps([])
114
115
  } finally {
115
- setLoading(false)
116
+ if (!signal?.aborted) setLoading(false)
116
117
  }
117
- }
118
+ }, [user])
118
119
 
119
- // Fetch when user identity or roles change (roles may update after registration)
120
+ // Fetch when user identity or roles change.
121
+ // Abort any in-flight request before starting a new one.
120
122
  const rolesKey = user?.roles?.join(',') || ''
121
123
  useEffect(() => {
122
- fetchApps()
123
- }, [user?.id, user?.account_id, rolesKey])
124
+ // Cancel previous fetch
125
+ abortRef.current?.abort()
126
+ const controller = new AbortController()
127
+ abortRef.current = controller
128
+
129
+ fetchApps(controller.signal)
130
+
131
+ return () => controller.abort()
132
+ }, [user?.id, user?.account_id, rolesKey, fetchApps])
124
133
 
125
134
  const routableApps = apps.filter(
126
135
  app => app.route_prefix != null && app.renderer !== 'none'
127
136
  )
128
137
 
138
+ const refetch = useCallback(() => {
139
+ abortRef.current?.abort()
140
+ const controller = new AbortController()
141
+ abortRef.current = controller
142
+ fetchApps(controller.signal)
143
+ }, [fetchApps])
144
+
129
145
  return (
130
- <AppsRegistryContext.Provider value={{ apps, routableApps, loading, error, refetch: fetchApps }}>
146
+ <AppsRegistryContext.Provider value={{ apps, routableApps, loading, error, refetch }}>
131
147
  {children}
132
148
  </AppsRegistryContext.Provider>
133
149
  )
@@ -10,33 +10,76 @@
10
10
  * fetch (principal, account, roles, permissions).
11
11
  *
12
12
  * **Session hydration strategy:**
13
- * 1. On mount, restore user from `sessionStorage` (survives page reloads
14
- * without a loading flash)
15
- * 2. If no stored user, call `checkAuth` `fetchUserContext` `setAccountId`
13
+ * 1. On mount, restore user from `localStorage` with TTL/version check
14
+ * (cross-tab sync, survives page reloads without a loading flash)
15
+ * 2. Always verify stored context with the server before rendering routes
16
16
  * 3. Subscribe to `supabase.auth.onAuthStateChange` for `SIGNED_IN`,
17
17
  * `TOKEN_REFRESHED`, and `SIGNED_OUT` events
18
18
  *
19
19
  * **Race-condition guards:**
20
- * - `isLoggingIn` flag prevents `onAuthStateChange` from re-fetching context
20
+ * - `isLoggingIn` ref prevents `onAuthStateChange` from re-fetching context
21
21
  * while `login()` is already in progress
22
- * - `SIGNED_IN` / `TOKEN_REFRESHED` handler skips re-fetch if user is already
23
- * loaded (prevents blocking page data fetches on browser focus)
24
- * - `checkAuth` exits early if user is already loaded from `sessionStorage`
22
+ * - `onAuthStateChange` checks current user ref before async fetch
23
+ * - `checkAuth` always verifies with server, even with stored user
25
24
  *
26
25
  * **Account scope:** Calls `setAccountId(userContext.account_id)` after
27
26
  * every successful context fetch, keeping `src/lib/api.ts` in sync.
28
27
  *
29
28
  * @seeAlso src/lib/supabase.ts (supabase client singleton)
30
- * @seeAlso src/lib/api.ts (setAccountId, apiFetch)
29
+ * @seeAlso src/lib/api.ts (setAccountId, apiFetch, onUnauthorized)
31
30
  * @seeAlso src/types/auth.ts (User shape)
32
31
  * @seeAlso functions/auth.ts (backend `/api/auth?action=context` endpoint)
33
32
  */
34
33
 
35
- import React, { createContext, useContext, useEffect, useState } from 'react'
34
+ import React, { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react'
36
35
  import { supabase } from '../lib/supabase'
37
- import { setAccountId, apiFetch } from '../lib/api'
36
+ import { setAccountId, apiFetch, onUnauthorized } from '../lib/api'
38
37
  import { User } from '../types/auth'
39
38
 
39
+ // ─── STORAGE ──────────────────────────────────────────────────────────────────
40
+
41
+ /** Version stamp for stored user; bump when User shape changes */
42
+ const STORAGE_VERSION = 1
43
+ const STORAGE_KEY = 'spine_user'
44
+ /** Max age (ms) before stored user is considered stale and discarded */
45
+ const STORAGE_TTL_MS = 5 * 60 * 1000 // 5 minutes
46
+
47
+ interface StoredUserEnvelope {
48
+ version: number
49
+ timestamp: number
50
+ user: User
51
+ }
52
+
53
+ function readStoredUser(): User | null {
54
+ try {
55
+ const raw = localStorage.getItem(STORAGE_KEY)
56
+ if (!raw) return null
57
+ const envelope: StoredUserEnvelope = JSON.parse(raw)
58
+ if (envelope.version !== STORAGE_VERSION) return null
59
+ if (Date.now() - envelope.timestamp > STORAGE_TTL_MS) return null
60
+ return envelope.user
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ function writeStoredUser(user: User | null) {
67
+ try {
68
+ if (user) {
69
+ const envelope: StoredUserEnvelope = {
70
+ version: STORAGE_VERSION,
71
+ timestamp: Date.now(),
72
+ user,
73
+ }
74
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope))
75
+ } else {
76
+ localStorage.removeItem(STORAGE_KEY)
77
+ }
78
+ } catch {
79
+ // Storage unavailable (private browsing, quota exceeded)
80
+ }
81
+ }
82
+
40
83
  // ─── TYPES ───────────────────────────────────────────────────────────────────
41
84
 
42
85
  /**
@@ -83,53 +126,60 @@ interface AuthProviderProps {
83
126
  // ─── INTERNAL HELPERS ───────────────────────────────────────────────────────────
84
127
 
85
128
  /**
86
- * Fetches the server-side user context from `GET /api/auth`. Returns null on
87
- * any error (network, auth, or API) rather than throwing, so callers can
88
- * apply fallback logic without try/catch.
129
+ * Fetches the server-side user context from `GET /api/auth` with retry.
130
+ * Returns null on any persistent error rather than throwing, so callers
131
+ * can apply fallback logic without try/catch.
132
+ *
133
+ * Retries up to `maxRetries` times on network errors and 5xx responses.
134
+ * Does NOT retry 401/403 (auth failures are not transient).
89
135
  *
90
136
  * @returns Resolved `User` object or null on failure
91
137
  * @throws never (all errors are caught internally)
92
138
  * @sideEffects Network request via apiFetch; console logging
93
139
  * @calledBy AuthProvider.login, AuthProvider.refreshUser, AuthProvider.checkAuth
94
140
  */
95
- async function fetchUserContext(): Promise<User | null> {
96
- try {
97
- console.log('Fetching user context from backend API')
141
+ async function fetchUserContext(maxRetries = 2): Promise<User | null> {
142
+ let lastError: unknown = null
98
143
 
99
- // Use backend API to get user context - backend handles all security
100
- const response = await apiFetch('/api/auth', {
101
- method: 'GET'
102
- })
144
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
145
+ try {
146
+ if (attempt > 0) {
147
+ // Exponential backoff: 200ms, 800ms, 3200ms
148
+ await new Promise(r => setTimeout(r, 200 * Math.pow(4, attempt - 1)))
149
+ }
103
150
 
104
- if (!response.ok) {
105
- console.error('Backend API error:', response.status, response.statusText)
106
- return null
107
- }
151
+ const response = await apiFetch('/api/auth', { method: 'GET' })
108
152
 
109
- const data = await response.json()
110
-
111
- if (data.error) {
112
- console.error('Backend returned error:', data.error)
113
- return null
114
- }
153
+ // Don't retry auth failures
154
+ if (response.status === 401 || response.status === 403) {
155
+ return null
156
+ }
115
157
 
116
- if (!data.data) {
117
- console.error('No user data returned from backend')
118
- return null
119
- }
158
+ // Retry on 5xx
159
+ if (response.status >= 500) {
160
+ lastError = new Error(`Server error: ${response.status}`)
161
+ continue
162
+ }
120
163
 
121
- console.log('User context loaded from backend:', {
122
- id: data.data.id,
123
- email: data.data.email,
124
- account: data.data.account?.slug,
125
- role: data.data.roles?.[0]
126
- })
164
+ if (!response.ok) {
165
+ return null
166
+ }
127
167
 
128
- return data.data
129
- } catch (error) {
130
- console.error('Error fetching user context from backend:', error)
131
- return null
168
+ const data = await response.json()
169
+ if (data.error || !data.data) return null
170
+
171
+ return data.data
172
+ } catch (error) {
173
+ lastError = error
174
+ // Network error (TypeError: Failed to fetch) — retry
175
+ if (error instanceof TypeError) continue
176
+ // Other errors — don't retry
177
+ return null
178
+ }
132
179
  }
180
+
181
+ console.error('fetchUserContext failed after retries:', lastError)
182
+ return null
133
183
  }
134
184
 
135
185
  // ─── AuthProvider ──────────────────────────────────────────────────────────────
@@ -140,9 +190,10 @@ async function fetchUserContext(): Promise<User | null> {
140
190
  *
141
191
  * @param children - React subtree to wrap
142
192
  * @sideEffects
143
- * - Reads/writes `sessionStorage` key `spine_user` for persistence
193
+ * - Reads/writes `localStorage` key `spine_user` for persistence
144
194
  * - Calls `setAccountId` (mutates `src/lib/api.ts` module state)
145
195
  * - Subscribes to `supabase.auth.onAuthStateChange`; unsubscribes on unmount
196
+ * - Registers 401 interceptor via `onUnauthorized`
146
197
  * @calledBy src/main.tsx (app root)
147
198
  *
148
199
  * @example
@@ -153,226 +204,202 @@ async function fetchUserContext(): Promise<User | null> {
153
204
  * ```
154
205
  */
155
206
  export function AuthProvider({ children }: AuthProviderProps) {
156
- // Initialize user from sessionStorage to survive full page reloads
157
- const getStoredUser = (): User | null => {
158
- try {
159
- const stored = sessionStorage.getItem('spine_user')
160
- return stored ? JSON.parse(stored) : null
161
- } catch {
162
- return null
163
- }
164
- }
207
+ const storedUser = readStoredUser()
208
+ const [user, setUser] = useState<User | null>(storedUser)
209
+ // If we have a stored user, start loading to verify context before rendering routes
210
+ const [isLoading, setIsLoading] = useState(true)
211
+ const isLoggingInRef = useRef(false)
212
+ const userRef = useRef<User | null>(storedUser)
213
+
214
+ // Keep ref in sync with state so async callbacks see current value
215
+ useEffect(() => { userRef.current = user }, [user])
216
+
217
+ const setUserWithStorage = useCallback((u: User | null) => {
218
+ setUser(u)
219
+ userRef.current = u
220
+ writeStoredUser(u)
221
+ }, [])
222
+
223
+ const clearUser = useCallback(() => {
224
+ setUserWithStorage(null)
225
+ setAccountId(null)
226
+ }, [setUserWithStorage])
227
+
228
+ const applyUserContext = useCallback((ctx: User) => {
229
+ setUserWithStorage(ctx)
230
+ setAccountId(ctx.account_id)
231
+ }, [setUserWithStorage])
165
232
 
166
- const [user, setUser] = useState<User | null>(getStoredUser)
167
- const [isLoading, setIsLoading] = useState(false) // Start with false, will set to true only if we need to check
168
- const [isLoggingIn, setIsLoggingIn] = useState(false)
233
+ // ── Login ──────────────────────────────────────────────────────────────────
234
+
235
+ const login = useCallback(async (email: string, password: string) => {
236
+ isLoggingInRef.current = true
169
237
 
170
- // Save user to sessionStorage whenever it changes
171
- const setUserWithStorage = (user: User | null) => {
172
- setUser(user)
173
238
  try {
174
- if (user) {
175
- sessionStorage.setItem('spine_user', JSON.stringify(user))
176
- } else {
177
- sessionStorage.removeItem('spine_user')
239
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password })
240
+
241
+ if (error) throw error
242
+
243
+ if (data.user) {
244
+ // Brief pause for session to establish
245
+ await new Promise(resolve => setTimeout(resolve, 100))
246
+
247
+ const userContext = await fetchUserContext()
248
+ if (userContext) {
249
+ applyUserContext(userContext)
250
+ } else {
251
+ // Fallback to minimal user object
252
+ applyUserContext({
253
+ id: data.user.id,
254
+ email: data.user.email || '',
255
+ full_name: data.user.user_metadata?.full_name || data.user.email || 'User',
256
+ account_id: '',
257
+ roles: [],
258
+ permissions: [],
259
+ })
260
+ }
178
261
  }
179
- } catch (error) {
180
- console.warn('Failed to save user to sessionStorage:', error)
262
+ } finally {
263
+ isLoggingInRef.current = false
181
264
  }
182
- }
265
+ }, [applyUserContext])
183
266
 
184
- const login = async (email: string, password: string) => {
185
- console.log('Login function called with:', email)
186
- setIsLoggingIn(true)
187
-
188
- const { data, error } = await supabase.auth.signInWithPassword({
189
- email,
190
- password,
191
- })
267
+ // ── Logout ─────────────────────────────────────────────────────────────────
192
268
 
193
- console.log('Supabase auth response:', { data, error })
269
+ const logout = useCallback(async () => {
270
+ await supabase.auth.signOut()
271
+ clearUser()
272
+ }, [clearUser])
273
+
274
+ // ── Refresh ────────────────────────────────────────────────────────────────
194
275
 
195
- if (error) {
196
- console.error('Supabase auth error:', error)
197
- setIsLoggingIn(false)
198
- throw error
276
+ const refreshUser = useCallback(async () => {
277
+ const { data: { session } } = await supabase.auth.getSession()
278
+
279
+ if (!session?.user) {
280
+ clearUser()
281
+ return
199
282
  }
200
283
 
201
- if (data.user) {
202
- console.log('User logged in, fetching server context...')
203
-
204
- // Wait a moment for the session to be properly established
205
- await new Promise(resolve => setTimeout(resolve, 100))
206
-
207
- // Fetch user context from server instead of hardcoding
208
- const userContext = await fetchUserContext()
209
- if (userContext) {
210
- console.log('Setting server-derived user context:', userContext)
211
- setUserWithStorage(userContext)
212
- setAccountId(userContext.account_id)
213
- } else {
214
- console.error('Failed to get user context from server')
215
- // Fallback to minimal user object
216
- const fallbackUser = {
217
- id: data.user.id,
218
- email: data.user.email || '',
219
- full_name: data.user.user_metadata?.full_name || data.user.email || 'User',
220
- account_id: '',
221
- roles: [],
222
- permissions: [],
223
- }
224
- setUserWithStorage(fallbackUser)
225
- setAccountId(fallbackUser.account_id)
226
- }
284
+ const userContext = await fetchUserContext()
285
+ if (userContext) {
286
+ applyUserContext(userContext)
287
+ } else {
288
+ clearUser()
227
289
  }
228
-
229
- setIsLoggingIn(false)
230
- }
290
+ }, [applyUserContext, clearUser])
231
291
 
232
- const logout = async () => {
233
- await supabase.auth.signOut()
234
- setUserWithStorage(null)
235
- setAccountId(null)
236
- }
292
+ // ── Register 401 interceptor ───────────────────────────────────────────────
237
293
 
238
- const refreshUser = async () => {
239
- try {
240
- console.log('Refreshing user...')
241
- const { data: { session } } = await supabase.auth.getSession()
242
- console.log('Got session:', session)
243
-
244
- if (!session?.user) {
245
- console.log('No session user, setting user to null')
246
- setUser(null)
247
- return
248
- }
294
+ useEffect(() => {
295
+ const unregister = onUnauthorized(() => {
296
+ // Session expired — sign out cleanly
297
+ supabase.auth.signOut().then(() => clearUser())
298
+ })
299
+ return unregister
300
+ }, [clearUser])
249
301
 
250
- // Fetch user context from server instead of hardcoding
251
- const userContext = await fetchUserContext()
252
- if (userContext) {
253
- console.log('Setting server-derived user context:', userContext)
254
- setUser(userContext)
255
- setAccountId(userContext.account_id)
256
- } else {
257
- console.error('Failed to get user context from server')
302
+ // ── Listen for storage events from other tabs ──────────────────────────────
303
+
304
+ useEffect(() => {
305
+ const handleStorage = (e: StorageEvent) => {
306
+ if (e.key !== STORAGE_KEY) return
307
+ if (e.newValue === null) {
308
+ // Other tab logged out
258
309
  setUser(null)
310
+ userRef.current = null
259
311
  setAccountId(null)
312
+ } else {
313
+ // Other tab logged in or refreshed
314
+ const parsed = readStoredUser()
315
+ if (parsed) {
316
+ setUser(parsed)
317
+ userRef.current = parsed
318
+ setAccountId(parsed.account_id)
319
+ }
260
320
  }
261
- } catch (error) {
262
- console.error('Error refreshing user:', error)
263
- setUser(null)
264
- setAccountId(null)
265
321
  }
266
- }
322
+ window.addEventListener('storage', handleStorage)
323
+ return () => window.removeEventListener('storage', handleStorage)
324
+ }, [])
325
+
326
+ // ── Initial auth check + subscription ──────────────────────────────────────
267
327
 
268
328
  useEffect(() => {
269
- // Check initial auth state
329
+ let cancelled = false
330
+
270
331
  const checkAuth = async () => {
271
332
  try {
272
- // If user is loaded from sessionStorage, skip the loading state but
273
- // still refresh context in the background to pick up role changes
274
- if (user) {
275
- console.log('User already loaded, refreshing context in background:', user.id)
276
- const { data: { session } } = await supabase.auth.getSession()
277
- if (session?.user) {
278
- const userContext = await fetchUserContext()
279
- if (userContext) {
280
- setUserWithStorage(userContext)
281
- setAccountId(userContext.account_id)
282
- }
283
- }
284
- setIsLoading(false)
285
- console.log('Auth check completed, loading set to false')
333
+ const { data: { session } } = await supabase.auth.getSession()
334
+
335
+ if (!session?.user) {
336
+ if (!cancelled) clearUser()
286
337
  return
287
338
  }
288
-
289
- // Only show loading if we actually need to check auth
290
- setIsLoading(true)
291
- console.log('Checking initial auth state...')
292
- const { data: { session } } = await supabase.auth.getSession()
293
- console.log('Initial session check:', session?.user?.id ? 'User found' : 'No user')
294
-
295
- if (session?.user) {
296
- // Fetch user context from server instead of hardcoding
297
- const userContext = await fetchUserContext()
298
- if (userContext) {
299
- console.log('Setting server-derived user context from session:', userContext)
300
- setUserWithStorage(userContext)
301
- setAccountId(userContext.account_id)
339
+
340
+ const userContext = await fetchUserContext()
341
+ if (cancelled) return
342
+
343
+ if (userContext) {
344
+ applyUserContext(userContext)
345
+ } else if (userRef.current) {
346
+ // Network issue but we have a stored user — keep it (offline resilience)
347
+ setAccountId(userRef.current.account_id)
348
+ } else {
349
+ clearUser()
350
+ }
351
+ } catch (error) {
352
+ if (!cancelled) {
353
+ // Network down with stored user — keep going (offline resilience)
354
+ if (userRef.current) {
355
+ setAccountId(userRef.current.account_id)
302
356
  } else {
303
- console.error('Failed to get user context from server')
304
- setUserWithStorage(null)
305
- setAccountId(null)
357
+ clearUser()
306
358
  }
307
359
  }
308
- } catch (error) {
309
- console.error('Auth check failed:', error)
310
- setUserWithStorage(null)
311
- setAccountId(null)
312
360
  } finally {
313
- // Always set loading to false after checking
314
- setIsLoading(false)
315
- console.log('Auth check completed, loading set to false')
361
+ if (!cancelled) setIsLoading(false)
316
362
  }
317
363
  }
318
364
 
319
365
  checkAuth()
320
366
 
321
- // Listen for auth changes
367
+ // Listen for auth changes — async work is in the callback body, not
368
+ // inside a state updater (React anti-pattern)
322
369
  const { data: { subscription } } = supabase.auth.onAuthStateChange(
323
370
  async (event, session) => {
324
- console.log('Auth state changed:', event, session?.user?.id)
325
-
371
+ if (cancelled) return
372
+
326
373
  // Don't refresh during login to avoid conflicts
327
- if (isLoggingIn) {
328
- console.log('Skipping refresh during login')
329
- return
330
- }
331
-
374
+ if (isLoggingInRef.current) return
375
+
332
376
  if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
333
- if (session?.user) {
334
- // Skip re-fetching if user is already loaded — prevents blocking
335
- // page fetches when Supabase re-fires SIGNED_IN on browser focus
336
- setUser(currentUser => {
337
- if (currentUser) {
338
- console.log('Auth state changed: user already loaded, skipping re-fetch')
339
- return currentUser
340
- }
341
- // No user yet — fetch context asynchronously
342
- fetchUserContext().then(userContext => {
343
- if (userContext) {
344
- console.log('Setting server-derived user context from auth state change:', userContext)
345
- setUserWithStorage(userContext)
346
- setAccountId(userContext.account_id)
347
- } else {
348
- console.error('Failed to get user context from server')
349
- setUserWithStorage(null)
350
- setAccountId(null)
351
- }
352
- })
353
- return currentUser
354
- })
377
+ if (!session?.user) return
378
+ // Skip re-fetch if user is already loaded — prevents blocking
379
+ // page fetches when Supabase re-fires SIGNED_IN on browser focus
380
+ if (userRef.current) return
381
+
382
+ const userContext = await fetchUserContext()
383
+ if (cancelled) return
384
+
385
+ if (userContext) {
386
+ applyUserContext(userContext)
387
+ } else {
388
+ clearUser()
355
389
  }
356
390
  } else if (event === 'SIGNED_OUT') {
357
- setUserWithStorage(null)
358
- setAccountId(null)
391
+ clearUser()
359
392
  }
360
393
  }
361
394
  )
362
395
 
363
- return () => subscription.unsubscribe()
364
- }, [isLoggingIn])
365
-
366
- const value = {
367
- user,
368
- isLoading,
369
- login,
370
- logout,
371
- refreshUser
372
- }
396
+ return () => {
397
+ cancelled = true
398
+ subscription.unsubscribe()
399
+ }
400
+ }, [applyUserContext, clearUser])
373
401
 
374
- // Debug: Log loading state changes
375
- console.log('AuthContext state:', { user: user?.id, isLoading, userEmail: user?.email })
402
+ const value: AuthContextType = { user, isLoading, login, logout, refreshUser }
376
403
 
377
404
  return (
378
405
  <AuthContext.Provider value={value}>
@@ -14,6 +14,10 @@
14
14
  * ```
15
15
  * All subsequent `apiFetch` calls include `X-Account-Id` automatically.
16
16
  *
17
+ * **401 interceptor:** Register a callback via `onUnauthorized(cb)` to be
18
+ * notified when any non-auth API call returns 401. AuthContext uses this to
19
+ * trigger logout when the session expires mid-use.
20
+ *
17
21
  * INVARIANT: Call `setAccountId` before making any authenticated API requests.
18
22
  * If `_accountId` is null, the backend may reject scoped requests.
19
23
  *
@@ -51,6 +55,26 @@ export function getAccountId(): string | null {
51
55
  return _accountId
52
56
  }
53
57
 
58
+ // ─── 401 INTERCEPTOR ─────────────────────────────────────────────────────────
59
+
60
+ /** Paths that are expected to return 401 (don't trigger the interceptor) */
61
+ const AUTH_PATHS = ['/api/auth']
62
+
63
+ let _onUnauthorizedCallback: (() => void) | null = null
64
+
65
+ /**
66
+ * Register a callback to be invoked when a non-auth API call returns 401.
67
+ * Returns an unregister function. Only one callback may be active at a time.
68
+ *
69
+ * @param cb - Callback invoked once on 401
70
+ * @returns Unregister function
71
+ * @calledBy AuthContext (registers logout handler)
72
+ */
73
+ export function onUnauthorized(cb: () => void): () => void {
74
+ _onUnauthorizedCallback = cb
75
+ return () => { _onUnauthorizedCallback = null }
76
+ }
77
+
54
78
  // ─── INTERNAL HELPERS ─────────────────────────────────────────────────────────
55
79
 
56
80
  /**
@@ -110,6 +134,9 @@ export async function getAuthHeaders(): Promise<Record<string, string>> {
110
134
  * headers, strips invalid Bearer values (`null`/`undefined`/empty), and
111
135
  * forwards all other options (including `signal` for AbortController).
112
136
  *
137
+ * If a non-auth endpoint returns 401, the registered `onUnauthorized`
138
+ * callback fires (typically triggers logout).
139
+ *
113
140
  * Use this for ALL Spine API calls from the frontend. Never call `fetch`
114
141
  * directly for API routes.
115
142
  *
@@ -120,7 +147,8 @@ export async function getAuthHeaders(): Promise<Record<string, string>> {
120
147
  * @inputSpec path: string — relative URL to a Netlify function
121
148
  * @inputSpec options.signal: AbortSignal | undefined — forwarded for cancellation
122
149
  * @outputSpec Response — with auth headers injected; not yet parsed
123
- * @sideEffects Network request; reads localStorage via getAuthHeaders
150
+ * @sideEffects Network request; reads localStorage via getAuthHeaders;
151
+ * may trigger onUnauthorized callback on 401
124
152
  * @calledBy src/hooks/useApi.ts, useEntityList.ts, useEntityRecord.ts
125
153
  *
126
154
  * @example
@@ -131,7 +159,6 @@ export async function getAuthHeaders(): Promise<Record<string, string>> {
131
159
  * ```
132
160
  */
133
161
  export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
134
- console.log('apiFetch called with:', { path, options, signal: options.signal })
135
162
  const authHeaders = await getAuthHeaders()
136
163
  const optionHeaders = normalizeHeaders(options.headers)
137
164
  const optionAuthorization = optionHeaders.Authorization || optionHeaders.authorization
@@ -151,6 +178,16 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
151
178
  ...optionHeaders,
152
179
  },
153
180
  }
154
- console.log('apiFetch final options:', { fetchOptions, signal: fetchOptions.signal })
155
- return fetch(path, fetchOptions)
181
+
182
+ const response = await fetch(path, fetchOptions)
183
+
184
+ // 401 on a non-auth endpoint means the session has expired
185
+ if (response.status === 401 && _onUnauthorizedCallback) {
186
+ const isAuthPath = AUTH_PATHS.some(p => path.startsWith(p))
187
+ if (!isAuthPath) {
188
+ _onUnauthorizedCallback()
189
+ }
190
+ }
191
+
192
+ return response
156
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.90",
3
+ "version": "0.3.92",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {