@sunboyoo/supabase-rbac 1.0.2
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/LICENSE +21 -0
- package/README.md +404 -0
- package/bin/rbac-init.js +122 -0
- package/dist/chunk-23MRNBGM.js +116 -0
- package/dist/chunk-ZFY3OHWO.js +54 -0
- package/dist/client.cjs +307 -0
- package/dist/client.d.cts +7 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +237 -0
- package/dist/index.cjs +209 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +18 -0
- package/dist/server.cjs +150 -0
- package/dist/server.d.cts +57 -0
- package/dist/server.d.ts +57 -0
- package/dist/server.js +6 -0
- package/dist/shared.cjs +82 -0
- package/dist/shared.d.cts +28 -0
- package/dist/shared.d.ts +28 -0
- package/dist/shared.js +14 -0
- package/dist/types-BayIl-Ha.d.cts +116 -0
- package/dist/types-BayIl-Ha.d.ts +116 -0
- package/migrations/20251229000000_create_rbac_schemas_and_extensions.sql +70 -0
- package/migrations/20251229000001_create_rbac_tables.sql +134 -0
- package/migrations/20251229000002_create_rbac_views.sql +70 -0
- package/migrations/20251229000003_create_rbac_functions_and_hook.sql +246 -0
- package/migrations/20251229000004_setup_rbac_grants_and_hardening.sql +97 -0
- package/migrations/20251229000005_setup_example_app1_orders_rls.sql +102 -0
- package/package.json +105 -0
package/dist/client.cjs
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/client.tsx
|
|
21
|
+
var client_exports = {};
|
|
22
|
+
__export(client_exports, {
|
|
23
|
+
RBACProvider: () => RBACProvider,
|
|
24
|
+
useRBAC: () => useRBAC
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(client_exports);
|
|
27
|
+
var import_react = require("react");
|
|
28
|
+
|
|
29
|
+
// src/shared.ts
|
|
30
|
+
var import_jose = require("jose");
|
|
31
|
+
var IS_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
32
|
+
function isUuid(v) {
|
|
33
|
+
return typeof v === "string" && IS_UUID.test(v);
|
|
34
|
+
}
|
|
35
|
+
function uniqueStable(arr) {
|
|
36
|
+
const seen = /* @__PURE__ */ new Set();
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const x of arr) {
|
|
39
|
+
if (seen.has(x)) continue;
|
|
40
|
+
seen.add(x);
|
|
41
|
+
out.push(x);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function decodeClaims(token) {
|
|
46
|
+
if (!token) return null;
|
|
47
|
+
try {
|
|
48
|
+
return (0, import_jose.decodeJwt)(token);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function getMergedRoleIds(params) {
|
|
54
|
+
const { token, appId, includeGlobal = true, globalAppId = "global" } = params;
|
|
55
|
+
const claims = decodeClaims(token);
|
|
56
|
+
const roleMap = claims?.app_metadata?.role_ids ?? {};
|
|
57
|
+
const appRoles = Array.isArray(roleMap[appId]) ? roleMap[appId] : [];
|
|
58
|
+
const gRoles = includeGlobal && Array.isArray(roleMap[globalAppId]) ? roleMap[globalAppId] : [];
|
|
59
|
+
const merged = uniqueStable([...appRoles, ...gRoles]).filter(isUuid);
|
|
60
|
+
const userId = isUuid(claims?.sub) ? claims.sub : null;
|
|
61
|
+
const exp = typeof claims?.exp === "number" ? claims.exp : null;
|
|
62
|
+
return { roleIds: merged, claims, userId, exp };
|
|
63
|
+
}
|
|
64
|
+
function hasRole(userRoleIds, required, mode = "any") {
|
|
65
|
+
if (!required || required.length === 0) return true;
|
|
66
|
+
if (!userRoleIds || userRoleIds.length === 0) return false;
|
|
67
|
+
const set = new Set(userRoleIds);
|
|
68
|
+
if (mode === "all") {
|
|
69
|
+
for (const r of required) if (!set.has(r)) return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
for (const r of required) if (set.has(r)) return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/client.tsx
|
|
77
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
78
|
+
var permissionCache = /* @__PURE__ */ new Map();
|
|
79
|
+
function cacheKey(appId, userId) {
|
|
80
|
+
return `${appId}|${userId ?? "anon"}`;
|
|
81
|
+
}
|
|
82
|
+
function log(logger, level, msg, meta) {
|
|
83
|
+
const fn = logger?.[level];
|
|
84
|
+
if (typeof fn === "function") fn(msg, meta);
|
|
85
|
+
}
|
|
86
|
+
var RBACContext = (0, import_react.createContext)(null);
|
|
87
|
+
function RBACProvider(props) {
|
|
88
|
+
const {
|
|
89
|
+
client,
|
|
90
|
+
appId,
|
|
91
|
+
includeGlobal = true,
|
|
92
|
+
globalAppId = "global",
|
|
93
|
+
permissions: permOptsRaw,
|
|
94
|
+
logger,
|
|
95
|
+
children
|
|
96
|
+
} = props;
|
|
97
|
+
const permOpts = {
|
|
98
|
+
enabled: permOptsRaw?.enabled ?? false,
|
|
99
|
+
rpcName: permOptsRaw?.rpcName ?? "get_my_permissions",
|
|
100
|
+
ttlMs: permOptsRaw?.ttlMs ?? 6e4,
|
|
101
|
+
nonBlocking: permOptsRaw?.nonBlocking ?? true
|
|
102
|
+
};
|
|
103
|
+
const sb = client;
|
|
104
|
+
const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
|
|
105
|
+
const [userId, setUserId] = (0, import_react.useState)(null);
|
|
106
|
+
const [roleIds, setRoleIds] = (0, import_react.useState)([]);
|
|
107
|
+
const [permissionNames, setPermissionNames] = (0, import_react.useState)([]);
|
|
108
|
+
const [isRBACLoading, setIsRBACLoading] = (0, import_react.useState)(true);
|
|
109
|
+
const [isPermissionLoading, setIsPermissionLoading] = (0, import_react.useState)(false);
|
|
110
|
+
const isRBACReady = !isRBACLoading;
|
|
111
|
+
const isPermissionReady = permOpts.enabled ? !isPermissionLoading : true;
|
|
112
|
+
const syncLock = (0, import_react.useRef)(null);
|
|
113
|
+
const permissionRequestId = (0, import_react.useRef)(0);
|
|
114
|
+
const sessionEpoch = (0, import_react.useRef)(0);
|
|
115
|
+
const pendingSync = (0, import_react.useRef)(null);
|
|
116
|
+
const fetchPermissionNames = (0, import_react.useCallback)(async (params) => {
|
|
117
|
+
if (!permOpts.enabled) return [];
|
|
118
|
+
if (!params.userId) return [];
|
|
119
|
+
const key = cacheKey(params.appId, params.userId);
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
const existing = permissionCache.get(key);
|
|
122
|
+
const fresh = existing && existing.inflight == null && now - existing.fetchedAt < permOpts.ttlMs && // EN: If token exp changed, treat cache as stale to avoid wrong UI
|
|
123
|
+
// 中文:token exp 变化则视为过期,避免 UI 错判
|
|
124
|
+
existing.tokenExp === params.tokenExp;
|
|
125
|
+
if (fresh) return existing.names;
|
|
126
|
+
if (existing?.inflight) return existing.inflight;
|
|
127
|
+
const inflight = (async () => {
|
|
128
|
+
setIsPermissionLoading(true);
|
|
129
|
+
try {
|
|
130
|
+
const { data, error } = await sb.rpc(permOpts.rpcName, { target_app_id: params.appId });
|
|
131
|
+
if (error) throw error;
|
|
132
|
+
const names = Array.isArray(data) ? data.map((x) => String(x?.permission_name ?? "")).filter((s) => s.length > 0) : [];
|
|
133
|
+
permissionCache.set(key, { fetchedAt: Date.now(), names, inflight: null, tokenExp: params.tokenExp });
|
|
134
|
+
return names;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
permissionCache.set(key, {
|
|
137
|
+
fetchedAt: existing?.fetchedAt ?? 0,
|
|
138
|
+
names: existing?.names ?? [],
|
|
139
|
+
inflight: null,
|
|
140
|
+
tokenExp: params.tokenExp
|
|
141
|
+
});
|
|
142
|
+
throw e;
|
|
143
|
+
} finally {
|
|
144
|
+
setIsPermissionLoading(false);
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
permissionCache.set(key, { fetchedAt: existing?.fetchedAt ?? 0, names: existing?.names ?? [], inflight, tokenExp: params.tokenExp });
|
|
148
|
+
return inflight;
|
|
149
|
+
}, [permOpts.enabled, permOpts.rpcName, permOpts.ttlMs, sb]);
|
|
150
|
+
const applySession = (0, import_react.useCallback)(async (session, epoch) => {
|
|
151
|
+
const accessToken = session?.access_token;
|
|
152
|
+
const authed = Boolean(session?.user?.id);
|
|
153
|
+
const { roleIds: merged, userId: parsedUid, exp } = getMergedRoleIds({
|
|
154
|
+
token: accessToken,
|
|
155
|
+
appId,
|
|
156
|
+
includeGlobal,
|
|
157
|
+
globalAppId
|
|
158
|
+
});
|
|
159
|
+
const uid = session?.user?.id && typeof session.user.id === "string" ? session.user.id : parsedUid;
|
|
160
|
+
if (epoch !== sessionEpoch.current) return;
|
|
161
|
+
setIsAuthenticated(authed);
|
|
162
|
+
setUserId(uid ?? null);
|
|
163
|
+
setRoleIds(merged);
|
|
164
|
+
const requestId = ++permissionRequestId.current;
|
|
165
|
+
if (!permOpts.enabled) {
|
|
166
|
+
setPermissionNames([]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const load = async () => {
|
|
170
|
+
try {
|
|
171
|
+
if (epoch !== sessionEpoch.current) return;
|
|
172
|
+
const names = await fetchPermissionNames({ appId, userId: uid ?? null, tokenExp: exp });
|
|
173
|
+
if (epoch !== sessionEpoch.current || requestId !== permissionRequestId.current) return;
|
|
174
|
+
setPermissionNames(names);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (epoch !== sessionEpoch.current || requestId !== permissionRequestId.current) return;
|
|
177
|
+
setPermissionNames([]);
|
|
178
|
+
log(logger, "warn", "[RBAC] permissions RPC failed; degraded to role-only gating.", e);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
if (permOpts.nonBlocking) void load();
|
|
182
|
+
else await load();
|
|
183
|
+
}, [appId, includeGlobal, globalAppId, permOpts.enabled, permOpts.nonBlocking, fetchPermissionNames, logger]);
|
|
184
|
+
const runSync = (0, import_react.useCallback)((forceSessionUpdate = false, sessionHint) => {
|
|
185
|
+
const promise = (async () => {
|
|
186
|
+
setIsRBACLoading(true);
|
|
187
|
+
try {
|
|
188
|
+
if (forceSessionUpdate && sb.auth.refreshSession) {
|
|
189
|
+
await sb.auth.refreshSession();
|
|
190
|
+
}
|
|
191
|
+
const session = sessionHint ? sessionHint : (await sb.auth.getSession()).data.session;
|
|
192
|
+
const epoch = sessionEpoch.current;
|
|
193
|
+
await applySession(session, epoch);
|
|
194
|
+
} finally {
|
|
195
|
+
setIsRBACLoading(false);
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
syncLock.current = promise;
|
|
199
|
+
promise.finally(() => {
|
|
200
|
+
const pending = pendingSync.current;
|
|
201
|
+
if (pending) {
|
|
202
|
+
pendingSync.current = null;
|
|
203
|
+
const next = runSync(pending.forceSessionUpdate, pending.sessionHint);
|
|
204
|
+
next.then(pending.resolve, pending.reject);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (syncLock.current === promise) {
|
|
208
|
+
syncLock.current = null;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return promise;
|
|
212
|
+
}, [applySession, sb]);
|
|
213
|
+
const sync = (0, import_react.useCallback)((forceSessionUpdate = false, sessionHint) => {
|
|
214
|
+
if (syncLock.current) {
|
|
215
|
+
if (!pendingSync.current) {
|
|
216
|
+
let resolve;
|
|
217
|
+
let reject;
|
|
218
|
+
const promise = new Promise((res, rej) => {
|
|
219
|
+
resolve = res;
|
|
220
|
+
reject = rej;
|
|
221
|
+
});
|
|
222
|
+
pendingSync.current = { forceSessionUpdate, sessionHint, promise, resolve, reject };
|
|
223
|
+
} else {
|
|
224
|
+
pendingSync.current.forceSessionUpdate = pendingSync.current.forceSessionUpdate || forceSessionUpdate;
|
|
225
|
+
if (sessionHint) {
|
|
226
|
+
pendingSync.current.sessionHint = sessionHint;
|
|
227
|
+
} else if (forceSessionUpdate) {
|
|
228
|
+
pendingSync.current.sessionHint = void 0;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return pendingSync.current.promise;
|
|
232
|
+
}
|
|
233
|
+
return runSync(forceSessionUpdate, sessionHint);
|
|
234
|
+
}, [runSync]);
|
|
235
|
+
(0, import_react.useEffect)(() => {
|
|
236
|
+
void sync(false);
|
|
237
|
+
const { data: { subscription } } = sb.auth.onAuthStateChange((event, session) => {
|
|
238
|
+
if (event === "SIGNED_OUT") {
|
|
239
|
+
sessionEpoch.current++;
|
|
240
|
+
const pending = pendingSync.current;
|
|
241
|
+
pendingSync.current = null;
|
|
242
|
+
if (pending) pending.resolve();
|
|
243
|
+
permissionRequestId.current++;
|
|
244
|
+
setIsAuthenticated(false);
|
|
245
|
+
setUserId(null);
|
|
246
|
+
setRoleIds([]);
|
|
247
|
+
setPermissionNames([]);
|
|
248
|
+
setIsRBACLoading(false);
|
|
249
|
+
setIsPermissionLoading(false);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
|
253
|
+
void sync(false, session);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
return () => subscription.unsubscribe();
|
|
257
|
+
}, [sb, sync]);
|
|
258
|
+
const refresh = (0, import_react.useCallback)(async (forceSessionUpdate = true) => {
|
|
259
|
+
await sync(forceSessionUpdate);
|
|
260
|
+
}, [sync]);
|
|
261
|
+
const hasRole2 = (0, import_react.useCallback)((required, mode = "any") => {
|
|
262
|
+
return hasRole(roleIds, required, mode);
|
|
263
|
+
}, [roleIds]);
|
|
264
|
+
const hasPermission = (0, import_react.useCallback)((permissionName) => {
|
|
265
|
+
if (!permOpts.enabled) return false;
|
|
266
|
+
if (!permissionName) return false;
|
|
267
|
+
return permissionNames.includes(permissionName);
|
|
268
|
+
}, [permOpts.enabled, permissionNames]);
|
|
269
|
+
const value = (0, import_react.useMemo)(() => ({
|
|
270
|
+
appId,
|
|
271
|
+
isAuthenticated,
|
|
272
|
+
userId,
|
|
273
|
+
roleIds,
|
|
274
|
+
permissionNames,
|
|
275
|
+
isRBACLoading,
|
|
276
|
+
isPermissionLoading,
|
|
277
|
+
isRBACReady,
|
|
278
|
+
isPermissionReady,
|
|
279
|
+
refresh,
|
|
280
|
+
hasRole: hasRole2,
|
|
281
|
+
hasPermission
|
|
282
|
+
}), [
|
|
283
|
+
appId,
|
|
284
|
+
isAuthenticated,
|
|
285
|
+
userId,
|
|
286
|
+
roleIds,
|
|
287
|
+
permissionNames,
|
|
288
|
+
isRBACLoading,
|
|
289
|
+
isPermissionLoading,
|
|
290
|
+
isRBACReady,
|
|
291
|
+
isPermissionReady,
|
|
292
|
+
refresh,
|
|
293
|
+
hasRole2,
|
|
294
|
+
hasPermission
|
|
295
|
+
]);
|
|
296
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RBACContext.Provider, { value, children });
|
|
297
|
+
}
|
|
298
|
+
function useRBAC() {
|
|
299
|
+
const ctx = (0, import_react.useContext)(RBACContext);
|
|
300
|
+
if (!ctx) throw new Error("useRBAC must be used within <RBACProvider>.");
|
|
301
|
+
return ctx;
|
|
302
|
+
}
|
|
303
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
304
|
+
0 && (module.exports = {
|
|
305
|
+
RBACProvider,
|
|
306
|
+
useRBAC
|
|
307
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { c as RBACProviderProps, d as RBACState } from './types-BayIl-Ha.cjs';
|
|
3
|
+
|
|
4
|
+
declare function RBACProvider(props: RBACProviderProps): react_jsx_runtime.JSX.Element;
|
|
5
|
+
declare function useRBAC(): RBACState;
|
|
6
|
+
|
|
7
|
+
export { RBACProvider, useRBAC };
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { c as RBACProviderProps, d as RBACState } from './types-BayIl-Ha.js';
|
|
3
|
+
|
|
4
|
+
declare function RBACProvider(props: RBACProviderProps): react_jsx_runtime.JSX.Element;
|
|
5
|
+
declare function useRBAC(): RBACState;
|
|
6
|
+
|
|
7
|
+
export { RBACProvider, useRBAC };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMergedRoleIds,
|
|
3
|
+
hasRole
|
|
4
|
+
} from "./chunk-ZFY3OHWO.js";
|
|
5
|
+
|
|
6
|
+
// src/client.tsx
|
|
7
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
var permissionCache = /* @__PURE__ */ new Map();
|
|
10
|
+
function cacheKey(appId, userId) {
|
|
11
|
+
return `${appId}|${userId ?? "anon"}`;
|
|
12
|
+
}
|
|
13
|
+
function log(logger, level, msg, meta) {
|
|
14
|
+
const fn = logger?.[level];
|
|
15
|
+
if (typeof fn === "function") fn(msg, meta);
|
|
16
|
+
}
|
|
17
|
+
var RBACContext = createContext(null);
|
|
18
|
+
function RBACProvider(props) {
|
|
19
|
+
const {
|
|
20
|
+
client,
|
|
21
|
+
appId,
|
|
22
|
+
includeGlobal = true,
|
|
23
|
+
globalAppId = "global",
|
|
24
|
+
permissions: permOptsRaw,
|
|
25
|
+
logger,
|
|
26
|
+
children
|
|
27
|
+
} = props;
|
|
28
|
+
const permOpts = {
|
|
29
|
+
enabled: permOptsRaw?.enabled ?? false,
|
|
30
|
+
rpcName: permOptsRaw?.rpcName ?? "get_my_permissions",
|
|
31
|
+
ttlMs: permOptsRaw?.ttlMs ?? 6e4,
|
|
32
|
+
nonBlocking: permOptsRaw?.nonBlocking ?? true
|
|
33
|
+
};
|
|
34
|
+
const sb = client;
|
|
35
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
36
|
+
const [userId, setUserId] = useState(null);
|
|
37
|
+
const [roleIds, setRoleIds] = useState([]);
|
|
38
|
+
const [permissionNames, setPermissionNames] = useState([]);
|
|
39
|
+
const [isRBACLoading, setIsRBACLoading] = useState(true);
|
|
40
|
+
const [isPermissionLoading, setIsPermissionLoading] = useState(false);
|
|
41
|
+
const isRBACReady = !isRBACLoading;
|
|
42
|
+
const isPermissionReady = permOpts.enabled ? !isPermissionLoading : true;
|
|
43
|
+
const syncLock = useRef(null);
|
|
44
|
+
const permissionRequestId = useRef(0);
|
|
45
|
+
const sessionEpoch = useRef(0);
|
|
46
|
+
const pendingSync = useRef(null);
|
|
47
|
+
const fetchPermissionNames = useCallback(async (params) => {
|
|
48
|
+
if (!permOpts.enabled) return [];
|
|
49
|
+
if (!params.userId) return [];
|
|
50
|
+
const key = cacheKey(params.appId, params.userId);
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const existing = permissionCache.get(key);
|
|
53
|
+
const fresh = existing && existing.inflight == null && now - existing.fetchedAt < permOpts.ttlMs && // EN: If token exp changed, treat cache as stale to avoid wrong UI
|
|
54
|
+
// 中文:token exp 变化则视为过期,避免 UI 错判
|
|
55
|
+
existing.tokenExp === params.tokenExp;
|
|
56
|
+
if (fresh) return existing.names;
|
|
57
|
+
if (existing?.inflight) return existing.inflight;
|
|
58
|
+
const inflight = (async () => {
|
|
59
|
+
setIsPermissionLoading(true);
|
|
60
|
+
try {
|
|
61
|
+
const { data, error } = await sb.rpc(permOpts.rpcName, { target_app_id: params.appId });
|
|
62
|
+
if (error) throw error;
|
|
63
|
+
const names = Array.isArray(data) ? data.map((x) => String(x?.permission_name ?? "")).filter((s) => s.length > 0) : [];
|
|
64
|
+
permissionCache.set(key, { fetchedAt: Date.now(), names, inflight: null, tokenExp: params.tokenExp });
|
|
65
|
+
return names;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
permissionCache.set(key, {
|
|
68
|
+
fetchedAt: existing?.fetchedAt ?? 0,
|
|
69
|
+
names: existing?.names ?? [],
|
|
70
|
+
inflight: null,
|
|
71
|
+
tokenExp: params.tokenExp
|
|
72
|
+
});
|
|
73
|
+
throw e;
|
|
74
|
+
} finally {
|
|
75
|
+
setIsPermissionLoading(false);
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
permissionCache.set(key, { fetchedAt: existing?.fetchedAt ?? 0, names: existing?.names ?? [], inflight, tokenExp: params.tokenExp });
|
|
79
|
+
return inflight;
|
|
80
|
+
}, [permOpts.enabled, permOpts.rpcName, permOpts.ttlMs, sb]);
|
|
81
|
+
const applySession = useCallback(async (session, epoch) => {
|
|
82
|
+
const accessToken = session?.access_token;
|
|
83
|
+
const authed = Boolean(session?.user?.id);
|
|
84
|
+
const { roleIds: merged, userId: parsedUid, exp } = getMergedRoleIds({
|
|
85
|
+
token: accessToken,
|
|
86
|
+
appId,
|
|
87
|
+
includeGlobal,
|
|
88
|
+
globalAppId
|
|
89
|
+
});
|
|
90
|
+
const uid = session?.user?.id && typeof session.user.id === "string" ? session.user.id : parsedUid;
|
|
91
|
+
if (epoch !== sessionEpoch.current) return;
|
|
92
|
+
setIsAuthenticated(authed);
|
|
93
|
+
setUserId(uid ?? null);
|
|
94
|
+
setRoleIds(merged);
|
|
95
|
+
const requestId = ++permissionRequestId.current;
|
|
96
|
+
if (!permOpts.enabled) {
|
|
97
|
+
setPermissionNames([]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const load = async () => {
|
|
101
|
+
try {
|
|
102
|
+
if (epoch !== sessionEpoch.current) return;
|
|
103
|
+
const names = await fetchPermissionNames({ appId, userId: uid ?? null, tokenExp: exp });
|
|
104
|
+
if (epoch !== sessionEpoch.current || requestId !== permissionRequestId.current) return;
|
|
105
|
+
setPermissionNames(names);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (epoch !== sessionEpoch.current || requestId !== permissionRequestId.current) return;
|
|
108
|
+
setPermissionNames([]);
|
|
109
|
+
log(logger, "warn", "[RBAC] permissions RPC failed; degraded to role-only gating.", e);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
if (permOpts.nonBlocking) void load();
|
|
113
|
+
else await load();
|
|
114
|
+
}, [appId, includeGlobal, globalAppId, permOpts.enabled, permOpts.nonBlocking, fetchPermissionNames, logger]);
|
|
115
|
+
const runSync = useCallback((forceSessionUpdate = false, sessionHint) => {
|
|
116
|
+
const promise = (async () => {
|
|
117
|
+
setIsRBACLoading(true);
|
|
118
|
+
try {
|
|
119
|
+
if (forceSessionUpdate && sb.auth.refreshSession) {
|
|
120
|
+
await sb.auth.refreshSession();
|
|
121
|
+
}
|
|
122
|
+
const session = sessionHint ? sessionHint : (await sb.auth.getSession()).data.session;
|
|
123
|
+
const epoch = sessionEpoch.current;
|
|
124
|
+
await applySession(session, epoch);
|
|
125
|
+
} finally {
|
|
126
|
+
setIsRBACLoading(false);
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
syncLock.current = promise;
|
|
130
|
+
promise.finally(() => {
|
|
131
|
+
const pending = pendingSync.current;
|
|
132
|
+
if (pending) {
|
|
133
|
+
pendingSync.current = null;
|
|
134
|
+
const next = runSync(pending.forceSessionUpdate, pending.sessionHint);
|
|
135
|
+
next.then(pending.resolve, pending.reject);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (syncLock.current === promise) {
|
|
139
|
+
syncLock.current = null;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return promise;
|
|
143
|
+
}, [applySession, sb]);
|
|
144
|
+
const sync = useCallback((forceSessionUpdate = false, sessionHint) => {
|
|
145
|
+
if (syncLock.current) {
|
|
146
|
+
if (!pendingSync.current) {
|
|
147
|
+
let resolve;
|
|
148
|
+
let reject;
|
|
149
|
+
const promise = new Promise((res, rej) => {
|
|
150
|
+
resolve = res;
|
|
151
|
+
reject = rej;
|
|
152
|
+
});
|
|
153
|
+
pendingSync.current = { forceSessionUpdate, sessionHint, promise, resolve, reject };
|
|
154
|
+
} else {
|
|
155
|
+
pendingSync.current.forceSessionUpdate = pendingSync.current.forceSessionUpdate || forceSessionUpdate;
|
|
156
|
+
if (sessionHint) {
|
|
157
|
+
pendingSync.current.sessionHint = sessionHint;
|
|
158
|
+
} else if (forceSessionUpdate) {
|
|
159
|
+
pendingSync.current.sessionHint = void 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return pendingSync.current.promise;
|
|
163
|
+
}
|
|
164
|
+
return runSync(forceSessionUpdate, sessionHint);
|
|
165
|
+
}, [runSync]);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
void sync(false);
|
|
168
|
+
const { data: { subscription } } = sb.auth.onAuthStateChange((event, session) => {
|
|
169
|
+
if (event === "SIGNED_OUT") {
|
|
170
|
+
sessionEpoch.current++;
|
|
171
|
+
const pending = pendingSync.current;
|
|
172
|
+
pendingSync.current = null;
|
|
173
|
+
if (pending) pending.resolve();
|
|
174
|
+
permissionRequestId.current++;
|
|
175
|
+
setIsAuthenticated(false);
|
|
176
|
+
setUserId(null);
|
|
177
|
+
setRoleIds([]);
|
|
178
|
+
setPermissionNames([]);
|
|
179
|
+
setIsRBACLoading(false);
|
|
180
|
+
setIsPermissionLoading(false);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
|
184
|
+
void sync(false, session);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return () => subscription.unsubscribe();
|
|
188
|
+
}, [sb, sync]);
|
|
189
|
+
const refresh = useCallback(async (forceSessionUpdate = true) => {
|
|
190
|
+
await sync(forceSessionUpdate);
|
|
191
|
+
}, [sync]);
|
|
192
|
+
const hasRole2 = useCallback((required, mode = "any") => {
|
|
193
|
+
return hasRole(roleIds, required, mode);
|
|
194
|
+
}, [roleIds]);
|
|
195
|
+
const hasPermission = useCallback((permissionName) => {
|
|
196
|
+
if (!permOpts.enabled) return false;
|
|
197
|
+
if (!permissionName) return false;
|
|
198
|
+
return permissionNames.includes(permissionName);
|
|
199
|
+
}, [permOpts.enabled, permissionNames]);
|
|
200
|
+
const value = useMemo(() => ({
|
|
201
|
+
appId,
|
|
202
|
+
isAuthenticated,
|
|
203
|
+
userId,
|
|
204
|
+
roleIds,
|
|
205
|
+
permissionNames,
|
|
206
|
+
isRBACLoading,
|
|
207
|
+
isPermissionLoading,
|
|
208
|
+
isRBACReady,
|
|
209
|
+
isPermissionReady,
|
|
210
|
+
refresh,
|
|
211
|
+
hasRole: hasRole2,
|
|
212
|
+
hasPermission
|
|
213
|
+
}), [
|
|
214
|
+
appId,
|
|
215
|
+
isAuthenticated,
|
|
216
|
+
userId,
|
|
217
|
+
roleIds,
|
|
218
|
+
permissionNames,
|
|
219
|
+
isRBACLoading,
|
|
220
|
+
isPermissionLoading,
|
|
221
|
+
isRBACReady,
|
|
222
|
+
isPermissionReady,
|
|
223
|
+
refresh,
|
|
224
|
+
hasRole2,
|
|
225
|
+
hasPermission
|
|
226
|
+
]);
|
|
227
|
+
return /* @__PURE__ */ jsx(RBACContext.Provider, { value, children });
|
|
228
|
+
}
|
|
229
|
+
function useRBAC() {
|
|
230
|
+
const ctx = useContext(RBACContext);
|
|
231
|
+
if (!ctx) throw new Error("useRBAC must be used within <RBACProvider>.");
|
|
232
|
+
return ctx;
|
|
233
|
+
}
|
|
234
|
+
export {
|
|
235
|
+
RBACProvider,
|
|
236
|
+
useRBAC
|
|
237
|
+
};
|