spine-framework 0.3.91 → 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,230 +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
- }
165
-
166
- const storedUser = getStoredUser()
207
+ const storedUser = readStoredUser()
167
208
  const [user, setUser] = useState<User | null>(storedUser)
168
209
  // If we have a stored user, start loading to verify context before rendering routes
169
- const [isLoading, setIsLoading] = useState(!!storedUser)
170
- const [isLoggingIn, setIsLoggingIn] = useState(false)
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])
232
+
233
+ // ── Login ──────────────────────────────────────────────────────────────────
234
+
235
+ const login = useCallback(async (email: string, password: string) => {
236
+ isLoggingInRef.current = true
171
237
 
172
- // Save user to sessionStorage whenever it changes
173
- const setUserWithStorage = (user: User | null) => {
174
- setUser(user)
175
238
  try {
176
- if (user) {
177
- sessionStorage.setItem('spine_user', JSON.stringify(user))
178
- } else {
179
- 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
+ }
180
261
  }
181
- } catch (error) {
182
- console.warn('Failed to save user to sessionStorage:', error)
262
+ } finally {
263
+ isLoggingInRef.current = false
183
264
  }
184
- }
265
+ }, [applyUserContext])
185
266
 
186
- const login = async (email: string, password: string) => {
187
- console.log('Login function called with:', email)
188
- setIsLoggingIn(true)
189
-
190
- const { data, error } = await supabase.auth.signInWithPassword({
191
- email,
192
- password,
193
- })
267
+ // ── Logout ─────────────────────────────────────────────────────────────────
268
+
269
+ const logout = useCallback(async () => {
270
+ await supabase.auth.signOut()
271
+ clearUser()
272
+ }, [clearUser])
194
273
 
195
- console.log('Supabase auth response:', { data, error })
274
+ // ── Refresh ────────────────────────────────────────────────────────────────
196
275
 
197
- if (error) {
198
- console.error('Supabase auth error:', error)
199
- setIsLoggingIn(false)
200
- throw error
276
+ const refreshUser = useCallback(async () => {
277
+ const { data: { session } } = await supabase.auth.getSession()
278
+
279
+ if (!session?.user) {
280
+ clearUser()
281
+ return
201
282
  }
202
283
 
203
- if (data.user) {
204
- console.log('User logged in, fetching server context...')
205
-
206
- // Wait a moment for the session to be properly established
207
- await new Promise(resolve => setTimeout(resolve, 100))
208
-
209
- // Fetch user context from server instead of hardcoding
210
- const userContext = await fetchUserContext()
211
- if (userContext) {
212
- console.log('Setting server-derived user context:', userContext)
213
- setUserWithStorage(userContext)
214
- setAccountId(userContext.account_id)
215
- } else {
216
- console.error('Failed to get user context from server')
217
- // Fallback to minimal user object
218
- const fallbackUser = {
219
- id: data.user.id,
220
- email: data.user.email || '',
221
- full_name: data.user.user_metadata?.full_name || data.user.email || 'User',
222
- account_id: '',
223
- roles: [],
224
- permissions: [],
225
- }
226
- setUserWithStorage(fallbackUser)
227
- setAccountId(fallbackUser.account_id)
228
- }
284
+ const userContext = await fetchUserContext()
285
+ if (userContext) {
286
+ applyUserContext(userContext)
287
+ } else {
288
+ clearUser()
229
289
  }
230
-
231
- setIsLoggingIn(false)
232
- }
290
+ }, [applyUserContext, clearUser])
233
291
 
234
- const logout = async () => {
235
- await supabase.auth.signOut()
236
- setUserWithStorage(null)
237
- setAccountId(null)
238
- }
292
+ // ── Register 401 interceptor ───────────────────────────────────────────────
239
293
 
240
- const refreshUser = async () => {
241
- try {
242
- console.log('Refreshing user...')
243
- const { data: { session } } = await supabase.auth.getSession()
244
- console.log('Got session:', session)
245
-
246
- if (!session?.user) {
247
- console.log('No session user, setting user to null')
248
- setUser(null)
249
- return
250
- }
294
+ useEffect(() => {
295
+ const unregister = onUnauthorized(() => {
296
+ // Session expired — sign out cleanly
297
+ supabase.auth.signOut().then(() => clearUser())
298
+ })
299
+ return unregister
300
+ }, [clearUser])
251
301
 
252
- // Fetch user context from server instead of hardcoding
253
- const userContext = await fetchUserContext()
254
- if (userContext) {
255
- console.log('Setting server-derived user context:', userContext)
256
- setUser(userContext)
257
- setAccountId(userContext.account_id)
258
- } else {
259
- 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
260
309
  setUser(null)
310
+ userRef.current = null
261
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
+ }
262
320
  }
263
- } catch (error) {
264
- console.error('Error refreshing user:', error)
265
- setUser(null)
266
- setAccountId(null)
267
321
  }
268
- }
322
+ window.addEventListener('storage', handleStorage)
323
+ return () => window.removeEventListener('storage', handleStorage)
324
+ }, [])
325
+
326
+ // ── Initial auth check + subscription ──────────────────────────────────────
269
327
 
270
328
  useEffect(() => {
271
- // Check initial auth state
329
+ let cancelled = false
330
+
272
331
  const checkAuth = async () => {
273
332
  try {
274
- // If user is loaded from sessionStorage, verify context from server
275
- // before rendering routes (isLoading starts true in this case)
276
- if (user) {
277
- console.log('User loaded from storage, verifying context:', user.id)
278
- const { data: { session } } = await supabase.auth.getSession()
279
- if (session?.user) {
280
- const userContext = await fetchUserContext()
281
- if (userContext) {
282
- setUserWithStorage(userContext)
283
- setAccountId(userContext.account_id)
284
- }
285
- } else {
286
- // Session expired — clear stored user
287
- setUserWithStorage(null)
288
- setAccountId(null)
289
- }
333
+ const { data: { session } } = await supabase.auth.getSession()
334
+
335
+ if (!session?.user) {
336
+ if (!cancelled) clearUser()
290
337
  return
291
338
  }
292
-
293
- // No stored user — show loading spinner while checking
294
- setIsLoading(true)
295
- console.log('Checking initial auth state...')
296
- const { data: { session } } = await supabase.auth.getSession()
297
- console.log('Initial session check:', session?.user?.id ? 'User found' : 'No user')
298
-
299
- if (session?.user) {
300
- // Fetch user context from server instead of hardcoding
301
- const userContext = await fetchUserContext()
302
- if (userContext) {
303
- console.log('Setting server-derived user context from session:', userContext)
304
- setUserWithStorage(userContext)
305
- 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)
306
356
  } else {
307
- console.error('Failed to get user context from server')
308
- setUserWithStorage(null)
309
- setAccountId(null)
357
+ clearUser()
310
358
  }
311
359
  }
312
- } catch (error) {
313
- console.error('Auth check failed:', error)
314
- setUserWithStorage(null)
315
- setAccountId(null)
316
360
  } finally {
317
- // Always set loading to false after checking
318
- setIsLoading(false)
319
- console.log('Auth check completed, loading set to false')
361
+ if (!cancelled) setIsLoading(false)
320
362
  }
321
363
  }
322
364
 
323
365
  checkAuth()
324
366
 
325
- // 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)
326
369
  const { data: { subscription } } = supabase.auth.onAuthStateChange(
327
370
  async (event, session) => {
328
- console.log('Auth state changed:', event, session?.user?.id)
329
-
371
+ if (cancelled) return
372
+
330
373
  // Don't refresh during login to avoid conflicts
331
- if (isLoggingIn) {
332
- console.log('Skipping refresh during login')
333
- return
334
- }
335
-
374
+ if (isLoggingInRef.current) return
375
+
336
376
  if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
337
- if (session?.user) {
338
- // Skip re-fetching if user is already loaded — prevents blocking
339
- // page fetches when Supabase re-fires SIGNED_IN on browser focus
340
- setUser(currentUser => {
341
- if (currentUser) {
342
- console.log('Auth state changed: user already loaded, skipping re-fetch')
343
- return currentUser
344
- }
345
- // No user yet — fetch context asynchronously
346
- fetchUserContext().then(userContext => {
347
- if (userContext) {
348
- console.log('Setting server-derived user context from auth state change:', userContext)
349
- setUserWithStorage(userContext)
350
- setAccountId(userContext.account_id)
351
- } else {
352
- console.error('Failed to get user context from server')
353
- setUserWithStorage(null)
354
- setAccountId(null)
355
- }
356
- })
357
- return currentUser
358
- })
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()
359
389
  }
360
390
  } else if (event === 'SIGNED_OUT') {
361
- setUserWithStorage(null)
362
- setAccountId(null)
391
+ clearUser()
363
392
  }
364
393
  }
365
394
  )
366
395
 
367
- return () => subscription.unsubscribe()
368
- }, [isLoggingIn])
369
-
370
- const value = {
371
- user,
372
- isLoading,
373
- login,
374
- logout,
375
- refreshUser
376
- }
396
+ return () => {
397
+ cancelled = true
398
+ subscription.unsubscribe()
399
+ }
400
+ }, [applyUserContext, clearUser])
377
401
 
378
- // Debug: Log loading state changes
379
- console.log('AuthContext state:', { user: user?.id, isLoading, userEmail: user?.email })
402
+ const value: AuthContextType = { user, isLoading, login, logout, refreshUser }
380
403
 
381
404
  return (
382
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.91",
3
+ "version": "0.3.92",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {