spine-framework 0.3.91 → 0.3.93
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.
- package/.framework/functions/apps.ts +8 -2
- package/.framework/migrations/019_triggers_unique_constraint.sql +13 -0
- package/.framework/migrations/020_get_account_apps_role_filter.sql +117 -0
- package/.framework/src/contexts/AppContext.tsx +39 -23
- package/.framework/src/contexts/AuthContext.tsx +247 -224
- package/.framework/src/lib/api.ts +41 -4
- package/package.json +3 -1
|
@@ -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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Migration 019: Add unique constraint on triggers(app_id, name)
|
|
2
|
+
--
|
|
3
|
+
-- The triggers table had no unique constraint, making idempotent upserts
|
|
4
|
+
-- impossible (re-runs would create duplicate rows). This adds a partial unique
|
|
5
|
+
-- index scoped to (app_id, name) so install-app can safely upsert seed triggers
|
|
6
|
+
-- using onConflict: 'app_id,name'.
|
|
7
|
+
--
|
|
8
|
+
-- NULL app_id rows (global triggers) are excluded from the uniqueness check
|
|
9
|
+
-- via the WHERE clause to avoid false conflicts between global triggers.
|
|
10
|
+
|
|
11
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_app_id_name
|
|
12
|
+
ON public.triggers(app_id, name)
|
|
13
|
+
WHERE app_id IS NOT NULL;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
-- Migration 020: Add role-based filtering to get_account_apps()
|
|
2
|
+
--
|
|
3
|
+
-- Previously the RPC returned all apps for an account and the frontend
|
|
4
|
+
-- filtered by role client-side. This was fragile (race conditions around
|
|
5
|
+
-- stale roles) and leaked app metadata to users who couldn't access them.
|
|
6
|
+
--
|
|
7
|
+
-- New signature adds two optional params:
|
|
8
|
+
-- user_role_slugs text[] — the calling user's role slugs (e.g. ARRAY['support'])
|
|
9
|
+
-- is_system_admin boolean — true bypasses all role checks
|
|
10
|
+
--
|
|
11
|
+
-- Filtering logic:
|
|
12
|
+
-- 1. system_admin always sees everything
|
|
13
|
+
-- 2. Apps with no required_roles AND no min_role are visible to everyone
|
|
14
|
+
-- 3. Apps with required_roles: user must have at least one matching role
|
|
15
|
+
-- 4. Apps with only min_role (legacy): treated as required_roles = ARRAY[min_role]
|
|
16
|
+
--
|
|
17
|
+
-- Existing callers that omit the new params get the old behaviour (no filtering).
|
|
18
|
+
|
|
19
|
+
DROP FUNCTION IF EXISTS public.get_account_apps(uuid, boolean, boolean);
|
|
20
|
+
DROP FUNCTION IF EXISTS public.get_account_apps(uuid, boolean, boolean, text[], boolean);
|
|
21
|
+
|
|
22
|
+
CREATE OR REPLACE FUNCTION public.get_account_apps(
|
|
23
|
+
account_id uuid,
|
|
24
|
+
include_system boolean DEFAULT true,
|
|
25
|
+
include_inactive boolean DEFAULT false,
|
|
26
|
+
user_role_slugs text[] DEFAULT NULL,
|
|
27
|
+
is_system_admin boolean DEFAULT false
|
|
28
|
+
)
|
|
29
|
+
RETURNS TABLE(
|
|
30
|
+
id uuid,
|
|
31
|
+
slug text,
|
|
32
|
+
name text,
|
|
33
|
+
description text,
|
|
34
|
+
icon text,
|
|
35
|
+
color text,
|
|
36
|
+
version text,
|
|
37
|
+
app_type text,
|
|
38
|
+
source text,
|
|
39
|
+
owner_account_id uuid,
|
|
40
|
+
is_active boolean,
|
|
41
|
+
is_system boolean,
|
|
42
|
+
min_role text,
|
|
43
|
+
required_roles jsonb,
|
|
44
|
+
config jsonb,
|
|
45
|
+
nav_items jsonb,
|
|
46
|
+
route_prefix text,
|
|
47
|
+
renderer text,
|
|
48
|
+
metadata jsonb,
|
|
49
|
+
integration_deps jsonb,
|
|
50
|
+
created_at timestamptz
|
|
51
|
+
)
|
|
52
|
+
LANGUAGE plpgsql
|
|
53
|
+
SECURITY DEFINER
|
|
54
|
+
AS $$
|
|
55
|
+
BEGIN
|
|
56
|
+
RETURN QUERY
|
|
57
|
+
SELECT
|
|
58
|
+
a.id,
|
|
59
|
+
a.slug,
|
|
60
|
+
a.name,
|
|
61
|
+
a.description,
|
|
62
|
+
a.icon,
|
|
63
|
+
a.color,
|
|
64
|
+
a.version,
|
|
65
|
+
a.app_type,
|
|
66
|
+
a.source,
|
|
67
|
+
a.owner_account_id,
|
|
68
|
+
a.is_active,
|
|
69
|
+
a.is_system,
|
|
70
|
+
a.min_role,
|
|
71
|
+
a.required_roles,
|
|
72
|
+
a.config,
|
|
73
|
+
a.nav_items,
|
|
74
|
+
a.route_prefix,
|
|
75
|
+
a.renderer,
|
|
76
|
+
a.metadata,
|
|
77
|
+
a.integration_deps,
|
|
78
|
+
a.created_at
|
|
79
|
+
FROM public.apps a
|
|
80
|
+
WHERE
|
|
81
|
+
-- Active filter
|
|
82
|
+
(include_inactive OR a.is_active = true)
|
|
83
|
+
-- Ownership / visibility filter
|
|
84
|
+
AND (
|
|
85
|
+
a.is_system = true
|
|
86
|
+
OR a.owner_account_id = get_account_apps.account_id
|
|
87
|
+
OR a.owner_account_id IS NULL
|
|
88
|
+
)
|
|
89
|
+
-- Role filter (only applied when user_role_slugs is provided)
|
|
90
|
+
AND (
|
|
91
|
+
user_role_slugs IS NULL -- no filter requested (backward compat)
|
|
92
|
+
OR is_system_admin -- system_admin sees everything
|
|
93
|
+
OR ( -- no role requirement on this app
|
|
94
|
+
(a.required_roles IS NULL OR a.required_roles = '[]'::jsonb)
|
|
95
|
+
AND a.min_role IS NULL
|
|
96
|
+
)
|
|
97
|
+
OR ( -- user has a matching required_role
|
|
98
|
+
a.required_roles IS NOT NULL
|
|
99
|
+
AND a.required_roles != '[]'::jsonb
|
|
100
|
+
AND EXISTS (
|
|
101
|
+
SELECT 1
|
|
102
|
+
FROM jsonb_array_elements_text(a.required_roles) AS rr(role_slug)
|
|
103
|
+
WHERE rr.role_slug = ANY(user_role_slugs)
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
OR ( -- legacy min_role fallback
|
|
107
|
+
(a.required_roles IS NULL OR a.required_roles = '[]'::jsonb)
|
|
108
|
+
AND a.min_role IS NOT NULL
|
|
109
|
+
AND a.min_role = ANY(user_role_slugs)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
ORDER BY
|
|
113
|
+
a.is_system DESC,
|
|
114
|
+
a.app_type,
|
|
115
|
+
a.name;
|
|
116
|
+
END;
|
|
117
|
+
$$;
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-framework",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.93",
|
|
4
4
|
"description": "Multi-tenant, modular application platform for modern SaaS systems",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
".framework/migrations/016_invites.sql",
|
|
26
26
|
".framework/migrations/017_fix_get_account_apps.sql",
|
|
27
27
|
".framework/migrations/018_apps_required_roles.sql",
|
|
28
|
+
".framework/migrations/019_triggers_unique_constraint.sql",
|
|
29
|
+
".framework/migrations/020_get_account_apps_role_filter.sql",
|
|
28
30
|
".framework/public/",
|
|
29
31
|
".framework/scripts/",
|
|
30
32
|
".framework/seeds/",
|