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
|
-
//
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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 `
|
|
14
|
-
* without a loading flash)
|
|
15
|
-
* 2.
|
|
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`
|
|
20
|
+
* - `isLoggingIn` ref prevents `onAuthStateChange` from re-fetching context
|
|
21
21
|
* while `login()` is already in progress
|
|
22
|
-
* - `
|
|
23
|
-
*
|
|
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
|
|
87
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
// Retry on 5xx
|
|
159
|
+
if (response.status >= 500) {
|
|
160
|
+
lastError = new Error(`Server error: ${response.status}`)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
120
163
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
account: data.data.account?.slug,
|
|
125
|
-
role: data.data.roles?.[0]
|
|
126
|
-
})
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
127
167
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 `
|
|
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
|
-
|
|
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(
|
|
170
|
-
const
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
}
|
|
182
|
-
|
|
262
|
+
} finally {
|
|
263
|
+
isLoggingInRef.current = false
|
|
183
264
|
}
|
|
184
|
-
}
|
|
265
|
+
}, [applyUserContext])
|
|
185
266
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
password,
|
|
193
|
-
})
|
|
267
|
+
// ── Logout ─────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const logout = useCallback(async () => {
|
|
270
|
+
await supabase.auth.signOut()
|
|
271
|
+
clearUser()
|
|
272
|
+
}, [clearUser])
|
|
194
273
|
|
|
195
|
-
|
|
274
|
+
// ── Refresh ────────────────────────────────────────────────────────────────
|
|
196
275
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
235
|
-
await supabase.auth.signOut()
|
|
236
|
-
setUserWithStorage(null)
|
|
237
|
-
setAccountId(null)
|
|
238
|
-
}
|
|
292
|
+
// ── Register 401 interceptor ───────────────────────────────────────────────
|
|
239
293
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
329
|
+
let cancelled = false
|
|
330
|
+
|
|
272
331
|
const checkAuth = async () => {
|
|
273
332
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (user) {
|
|
277
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
371
|
+
if (cancelled) return
|
|
372
|
+
|
|
330
373
|
// Don't refresh during login to avoid conflicts
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
|
|
374
|
+
if (isLoggingInRef.current) return
|
|
375
|
+
|
|
336
376
|
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
|
|
337
|
-
if (session?.user)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
362
|
-
setAccountId(null)
|
|
391
|
+
clearUser()
|
|
363
392
|
}
|
|
364
393
|
}
|
|
365
394
|
)
|
|
366
395
|
|
|
367
|
-
return () =>
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
isLoading,
|
|
373
|
-
login,
|
|
374
|
-
logout,
|
|
375
|
-
refreshUser
|
|
376
|
-
}
|
|
396
|
+
return () => {
|
|
397
|
+
cancelled = true
|
|
398
|
+
subscription.unsubscribe()
|
|
399
|
+
}
|
|
400
|
+
}, [applyUserContext, clearUser])
|
|
377
401
|
|
|
378
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
}
|