preact-missing-hooks 4.4.0 → 4.5.0
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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.modern.mjs +1 -1
- package/dist/index.modern.mjs.map +1 -1
- package/dist/index.module.js +1 -1
- package/dist/index.module.js.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/react.js +233 -0
- package/dist/useRBAC.d.ts +102 -0
- package/docs/index.html +50 -0
- package/docs/main.js +98 -0
- package/package.json +6 -1
- package/src/index.ts +1 -0
- package/src/useRBAC.ts +380 -0
- package/tests/useRBAC.test.tsx +212 -0
package/src/useRBAC.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
2
|
+
|
|
3
|
+
/** Flexible user object; your app can use any shape. */
|
|
4
|
+
export type RBACUser = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
/** Get auth state (user + optional roles/capabilities). Used by custom source. */
|
|
7
|
+
export interface RBACAuthState {
|
|
8
|
+
user?: RBACUser | null;
|
|
9
|
+
roles?: string[];
|
|
10
|
+
capabilities?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Pluggable source for current user (and optionally roles/capabilities). */
|
|
14
|
+
export type RBACUserSource =
|
|
15
|
+
| { type: "localStorage"; key: string }
|
|
16
|
+
| { type: "sessionStorage"; key: string }
|
|
17
|
+
| { type: "api"; fetch: () => Promise<RBACUser> }
|
|
18
|
+
| { type: "memory"; getUser: () => RBACUser | null }
|
|
19
|
+
| { type: "custom"; getAuth: () => RBACAuthState | Promise<RBACAuthState> };
|
|
20
|
+
|
|
21
|
+
/** Role definition: name + condition to grant this role based on user. */
|
|
22
|
+
export interface RBACRoleDefinition {
|
|
23
|
+
role: string;
|
|
24
|
+
condition: (user: RBACUser | null) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Role name -> list of capability strings. Use '*' for full access. */
|
|
28
|
+
export type RBACRoleCapabilities = Record<string, string[]>;
|
|
29
|
+
|
|
30
|
+
/** Optional override: get capabilities directly (e.g. from API) instead of deriving from roles. */
|
|
31
|
+
export type RBACCapabilitiesOverride =
|
|
32
|
+
| { type: "localStorage"; key: string }
|
|
33
|
+
| { type: "sessionStorage"; key: string }
|
|
34
|
+
| { type: "api"; fetch: () => Promise<string[]> };
|
|
35
|
+
|
|
36
|
+
export interface UseRBACOptions {
|
|
37
|
+
/** Where to get the current user (and optionally roles/capabilities if type is 'custom'). */
|
|
38
|
+
userSource: RBACUserSource;
|
|
39
|
+
/** Role definitions: each role has a condition(user) to determine if the user has that role. */
|
|
40
|
+
roleDefinitions: RBACRoleDefinition[];
|
|
41
|
+
/** Capabilities per role. User gets union of capabilities for all their roles. Use '*' for admin. */
|
|
42
|
+
roleCapabilities: RBACRoleCapabilities;
|
|
43
|
+
/** Optional: fetch capabilities directly (overrides role-derived capabilities when provided). */
|
|
44
|
+
capabilitiesOverride?: RBACCapabilitiesOverride;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseRBACReturn {
|
|
48
|
+
/** Current user from source, or null. */
|
|
49
|
+
user: RBACUser | null;
|
|
50
|
+
/** Resolved roles for the current user. */
|
|
51
|
+
roles: string[];
|
|
52
|
+
/** Resolved capabilities (union of role capabilities, or from override). */
|
|
53
|
+
capabilities: string[];
|
|
54
|
+
/** True when user/roles/capabilities have been resolved (or failed). */
|
|
55
|
+
isReady: boolean;
|
|
56
|
+
/** Error from source (e.g. API or parse). */
|
|
57
|
+
error: Error | null;
|
|
58
|
+
/** Check if the user has the given role. */
|
|
59
|
+
hasRole: (role: string) => boolean;
|
|
60
|
+
/** Check if the user has the given capability (or '*' ). */
|
|
61
|
+
hasCapability: (capability: string) => boolean;
|
|
62
|
+
/** Alias for hasCapability. */
|
|
63
|
+
can: (capability: string) => boolean;
|
|
64
|
+
/** Re-fetch user/roles/capabilities from source. */
|
|
65
|
+
refetch: () => Promise<void>;
|
|
66
|
+
/** Helpers to persist auth to storage (for frontend-only flows). */
|
|
67
|
+
setUserInStorage: (
|
|
68
|
+
user: RBACUser | null,
|
|
69
|
+
storage: "localStorage" | "sessionStorage",
|
|
70
|
+
key: string
|
|
71
|
+
) => void;
|
|
72
|
+
setRolesInStorage: (
|
|
73
|
+
roles: string[],
|
|
74
|
+
storage: "localStorage" | "sessionStorage",
|
|
75
|
+
key: string
|
|
76
|
+
) => void;
|
|
77
|
+
setCapabilitiesInStorage: (
|
|
78
|
+
capabilities: string[],
|
|
79
|
+
storage: "localStorage" | "sessionStorage",
|
|
80
|
+
key: string
|
|
81
|
+
) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const WILDCARD = "*";
|
|
85
|
+
|
|
86
|
+
function parseUserFromStorage(
|
|
87
|
+
type: "localStorage" | "sessionStorage",
|
|
88
|
+
key: string
|
|
89
|
+
): RBACUser | null {
|
|
90
|
+
if (typeof window === "undefined") return null;
|
|
91
|
+
const storage =
|
|
92
|
+
type === "localStorage" ? window.localStorage : window.sessionStorage;
|
|
93
|
+
try {
|
|
94
|
+
const raw = storage.getItem(key);
|
|
95
|
+
if (raw == null) return null;
|
|
96
|
+
const data = JSON.parse(raw) as unknown;
|
|
97
|
+
return data && typeof data === "object" ? (data as RBACUser) : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseCapabilitiesFromStorage(
|
|
104
|
+
type: "localStorage" | "sessionStorage",
|
|
105
|
+
key: string
|
|
106
|
+
): string[] {
|
|
107
|
+
if (typeof window === "undefined") return [];
|
|
108
|
+
const storage =
|
|
109
|
+
type === "localStorage" ? window.localStorage : window.sessionStorage;
|
|
110
|
+
try {
|
|
111
|
+
const raw = storage.getItem(key);
|
|
112
|
+
if (raw == null) return [];
|
|
113
|
+
const data = JSON.parse(raw) as unknown;
|
|
114
|
+
return Array.isArray(data)
|
|
115
|
+
? data.filter((x): x is string => typeof x === "string")
|
|
116
|
+
: [];
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function computeRoles(
|
|
123
|
+
user: RBACUser | null,
|
|
124
|
+
roleDefinitions: RBACRoleDefinition[]
|
|
125
|
+
): string[] {
|
|
126
|
+
return roleDefinitions
|
|
127
|
+
.filter((def) => def.condition(user))
|
|
128
|
+
.map((def) => def.role);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function computeCapabilitiesFromRoles(
|
|
132
|
+
roles: string[],
|
|
133
|
+
roleCapabilities: RBACRoleCapabilities
|
|
134
|
+
): string[] {
|
|
135
|
+
const set = new Set<string>();
|
|
136
|
+
for (const role of roles) {
|
|
137
|
+
const caps = roleCapabilities[role];
|
|
138
|
+
if (Array.isArray(caps)) {
|
|
139
|
+
for (const c of caps) set.add(c);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return Array.from(set);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasCapabilityImpl(
|
|
146
|
+
capabilities: string[],
|
|
147
|
+
capability: string
|
|
148
|
+
): boolean {
|
|
149
|
+
if (capabilities.includes(WILDCARD)) return true;
|
|
150
|
+
return capabilities.includes(capability);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Frontend-only role-based access control hook. Define roles with conditions,
|
|
155
|
+
* assign capabilities per role, and plug in user source (localStorage, sessionStorage, API, or custom).
|
|
156
|
+
* Supports full flexibility: frontend-only with storage or pluggable API.
|
|
157
|
+
*
|
|
158
|
+
* @param options - userSource, roleDefinitions, roleCapabilities, optional capabilitiesOverride
|
|
159
|
+
* @returns user, roles, capabilities, hasRole, hasCapability, can, isReady, error, refetch, and storage helpers
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```tsx
|
|
163
|
+
* const { hasRole, can, roles, setUserInStorage } = useRBAC({
|
|
164
|
+
* userSource: { type: 'localStorage', key: 'user' },
|
|
165
|
+
* roleDefinitions: [
|
|
166
|
+
* { role: 'admin', condition: (u) => u?.role === 'admin' },
|
|
167
|
+
* { role: 'editor', condition: (u) => u?.role === 'editor' || u?.role === 'admin' },
|
|
168
|
+
* ],
|
|
169
|
+
* roleCapabilities: {
|
|
170
|
+
* admin: ['*'],
|
|
171
|
+
* editor: ['posts:edit', 'posts:create'],
|
|
172
|
+
* },
|
|
173
|
+
* });
|
|
174
|
+
* if (can('posts:edit')) { ... }
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function useRBAC(options: UseRBACOptions): UseRBACReturn {
|
|
178
|
+
const { userSource } = options;
|
|
179
|
+
|
|
180
|
+
const optsRef = useRef(options);
|
|
181
|
+
optsRef.current = options;
|
|
182
|
+
|
|
183
|
+
const [user, setUser] = useState<RBACUser | null>(null);
|
|
184
|
+
const [roles, setRoles] = useState<string[]>([]);
|
|
185
|
+
const [capabilities, setCapabilities] = useState<string[]>([]);
|
|
186
|
+
const [isReady, setIsReady] = useState(false);
|
|
187
|
+
const [error, setError] = useState<Error | null>(null);
|
|
188
|
+
|
|
189
|
+
const resolveAuth = useCallback(async () => {
|
|
190
|
+
const {
|
|
191
|
+
userSource: us,
|
|
192
|
+
roleDefinitions: rdefs,
|
|
193
|
+
roleCapabilities: rcaps,
|
|
194
|
+
capabilitiesOverride: capOver,
|
|
195
|
+
} = optsRef.current;
|
|
196
|
+
setError(null);
|
|
197
|
+
let resolvedUser: RBACUser | null = null;
|
|
198
|
+
let resolvedRoles: string[] = [];
|
|
199
|
+
let resolvedCapabilities: string[] = [];
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
if (us.type === "localStorage") {
|
|
203
|
+
resolvedUser = parseUserFromStorage("localStorage", us.key);
|
|
204
|
+
resolvedRoles = computeRoles(resolvedUser, rdefs);
|
|
205
|
+
resolvedCapabilities = computeCapabilitiesFromRoles(
|
|
206
|
+
resolvedRoles,
|
|
207
|
+
rcaps
|
|
208
|
+
);
|
|
209
|
+
} else if (us.type === "sessionStorage") {
|
|
210
|
+
resolvedUser = parseUserFromStorage("sessionStorage", us.key);
|
|
211
|
+
resolvedRoles = computeRoles(resolvedUser, rdefs);
|
|
212
|
+
resolvedCapabilities = computeCapabilitiesFromRoles(
|
|
213
|
+
resolvedRoles,
|
|
214
|
+
rcaps
|
|
215
|
+
);
|
|
216
|
+
} else if (us.type === "api") {
|
|
217
|
+
resolvedUser = await us.fetch();
|
|
218
|
+
resolvedRoles = computeRoles(resolvedUser, rdefs);
|
|
219
|
+
resolvedCapabilities = computeCapabilitiesFromRoles(
|
|
220
|
+
resolvedRoles,
|
|
221
|
+
rcaps
|
|
222
|
+
);
|
|
223
|
+
} else if (us.type === "memory") {
|
|
224
|
+
resolvedUser = us.getUser();
|
|
225
|
+
resolvedRoles = computeRoles(resolvedUser, rdefs);
|
|
226
|
+
resolvedCapabilities = computeCapabilitiesFromRoles(
|
|
227
|
+
resolvedRoles,
|
|
228
|
+
rcaps
|
|
229
|
+
);
|
|
230
|
+
} else if (us.type === "custom") {
|
|
231
|
+
const auth = await Promise.resolve(us.getAuth());
|
|
232
|
+
resolvedUser = auth.user ?? null;
|
|
233
|
+
resolvedRoles = auth.roles ?? computeRoles(resolvedUser, rdefs);
|
|
234
|
+
resolvedCapabilities =
|
|
235
|
+
auth.capabilities ??
|
|
236
|
+
computeCapabilitiesFromRoles(resolvedRoles, rcaps);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (capOver) {
|
|
240
|
+
if (capOver.type === "localStorage") {
|
|
241
|
+
const override = parseCapabilitiesFromStorage(
|
|
242
|
+
"localStorage",
|
|
243
|
+
capOver.key
|
|
244
|
+
);
|
|
245
|
+
if (override.length > 0) resolvedCapabilities = override;
|
|
246
|
+
} else if (capOver.type === "sessionStorage") {
|
|
247
|
+
const override = parseCapabilitiesFromStorage(
|
|
248
|
+
"sessionStorage",
|
|
249
|
+
capOver.key
|
|
250
|
+
);
|
|
251
|
+
if (override.length > 0) resolvedCapabilities = override;
|
|
252
|
+
} else if (capOver.type === "api") {
|
|
253
|
+
const override = await capOver.fetch();
|
|
254
|
+
resolvedCapabilities = override;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setUser(resolvedUser);
|
|
259
|
+
setRoles(resolvedRoles);
|
|
260
|
+
setCapabilities(resolvedCapabilities);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
263
|
+
setUser(null);
|
|
264
|
+
setRoles([]);
|
|
265
|
+
setCapabilities([]);
|
|
266
|
+
} finally {
|
|
267
|
+
setIsReady(true);
|
|
268
|
+
}
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
resolveAuth();
|
|
273
|
+
}, [resolveAuth]);
|
|
274
|
+
|
|
275
|
+
// Listen to storage events when using localStorage/sessionStorage so we refetch when another tab changes data
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (typeof window === "undefined") return;
|
|
278
|
+
const key =
|
|
279
|
+
userSource.type === "localStorage" || userSource.type === "sessionStorage"
|
|
280
|
+
? userSource.key
|
|
281
|
+
: null;
|
|
282
|
+
if (!key) return;
|
|
283
|
+
const handler = (e: StorageEvent) => {
|
|
284
|
+
if (e.key === key) void resolveAuth();
|
|
285
|
+
};
|
|
286
|
+
window.addEventListener("storage", handler);
|
|
287
|
+
return () => window.removeEventListener("storage", handler);
|
|
288
|
+
}, [
|
|
289
|
+
userSource.type,
|
|
290
|
+
userSource.type === "localStorage" || userSource.type === "sessionStorage"
|
|
291
|
+
? (userSource as { key: string }).key
|
|
292
|
+
: "",
|
|
293
|
+
resolveAuth,
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const hasRole = useCallback((role: string) => roles.includes(role), [roles]);
|
|
297
|
+
|
|
298
|
+
const hasCapability = useCallback(
|
|
299
|
+
(capability: string) => hasCapabilityImpl(capabilities, capability),
|
|
300
|
+
[capabilities]
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const can = hasCapability;
|
|
304
|
+
|
|
305
|
+
const refetch = useCallback(() => resolveAuth(), [resolveAuth]);
|
|
306
|
+
|
|
307
|
+
const setUserInStorage = useCallback(
|
|
308
|
+
(
|
|
309
|
+
newUser: RBACUser | null,
|
|
310
|
+
storage: "localStorage" | "sessionStorage",
|
|
311
|
+
key: string
|
|
312
|
+
) => {
|
|
313
|
+
if (typeof window === "undefined") return;
|
|
314
|
+
const s =
|
|
315
|
+
storage === "localStorage"
|
|
316
|
+
? window.localStorage
|
|
317
|
+
: window.sessionStorage;
|
|
318
|
+
if (newUser == null) s.removeItem(key);
|
|
319
|
+
else s.setItem(key, JSON.stringify(newUser));
|
|
320
|
+
if (
|
|
321
|
+
(userSource.type === "localStorage" &&
|
|
322
|
+
storage === "localStorage" &&
|
|
323
|
+
userSource.key === key) ||
|
|
324
|
+
(userSource.type === "sessionStorage" &&
|
|
325
|
+
storage === "sessionStorage" &&
|
|
326
|
+
userSource.key === key)
|
|
327
|
+
) {
|
|
328
|
+
void resolveAuth();
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
[userSource, resolveAuth]
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const setRolesInStorage = useCallback(
|
|
335
|
+
(
|
|
336
|
+
newRoles: string[],
|
|
337
|
+
storage: "localStorage" | "sessionStorage",
|
|
338
|
+
key: string
|
|
339
|
+
) => {
|
|
340
|
+
if (typeof window === "undefined") return;
|
|
341
|
+
const s =
|
|
342
|
+
storage === "localStorage"
|
|
343
|
+
? window.localStorage
|
|
344
|
+
: window.sessionStorage;
|
|
345
|
+
s.setItem(key, JSON.stringify(newRoles));
|
|
346
|
+
},
|
|
347
|
+
[]
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const setCapabilitiesInStorage = useCallback(
|
|
351
|
+
(
|
|
352
|
+
newCaps: string[],
|
|
353
|
+
storage: "localStorage" | "sessionStorage",
|
|
354
|
+
key: string
|
|
355
|
+
) => {
|
|
356
|
+
if (typeof window === "undefined") return;
|
|
357
|
+
const s =
|
|
358
|
+
storage === "localStorage"
|
|
359
|
+
? window.localStorage
|
|
360
|
+
: window.sessionStorage;
|
|
361
|
+
s.setItem(key, JSON.stringify(newCaps));
|
|
362
|
+
},
|
|
363
|
+
[]
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
user,
|
|
368
|
+
roles,
|
|
369
|
+
capabilities,
|
|
370
|
+
isReady,
|
|
371
|
+
error,
|
|
372
|
+
hasRole,
|
|
373
|
+
hasCapability,
|
|
374
|
+
can,
|
|
375
|
+
refetch,
|
|
376
|
+
setUserInStorage,
|
|
377
|
+
setRolesInStorage,
|
|
378
|
+
setCapabilitiesInStorage,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** @jsx h */
|
|
2
|
+
import { h } from "preact";
|
|
3
|
+
import { useRef } from "preact/hooks";
|
|
4
|
+
import { render, fireEvent, waitFor } from "@testing-library/preact";
|
|
5
|
+
import { useRBAC } from "../src/useRBAC";
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = "rbac-test-user";
|
|
9
|
+
const ROLE_DEFS = [
|
|
10
|
+
{ role: "admin", condition: (u: Record<string, unknown> | null) => u?.role === "admin" },
|
|
11
|
+
{ role: "editor", condition: (u: Record<string, unknown> | null) => u?.role === "editor" || u?.role === "admin" },
|
|
12
|
+
{ role: "viewer", condition: (u: Record<string, unknown> | null) => !!u?.id },
|
|
13
|
+
];
|
|
14
|
+
const ROLE_CAPS: Record<string, string[]> = {
|
|
15
|
+
admin: ["*"],
|
|
16
|
+
editor: ["posts:edit", "posts:create", "posts:read"],
|
|
17
|
+
viewer: ["posts:read"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("useRBAC", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
localStorage.clear();
|
|
23
|
+
sessionStorage.clear();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns isReady, user, roles, capabilities, hasRole, can, refetch", async () => {
|
|
27
|
+
function TestComponent() {
|
|
28
|
+
const rbac = useRBAC({
|
|
29
|
+
userSource: { type: "localStorage", key: STORAGE_KEY },
|
|
30
|
+
roleDefinitions: ROLE_DEFS,
|
|
31
|
+
roleCapabilities: ROLE_CAPS,
|
|
32
|
+
});
|
|
33
|
+
return h("div", {},
|
|
34
|
+
h("span", { "data-testid": "ready" }, String(rbac.isReady)),
|
|
35
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
36
|
+
h("span", { "data-testid": "caps" }, rbac.capabilities.join(",")),
|
|
37
|
+
h("button", { onClick: rbac.refetch }, "Refetch"),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const { getByTestId, getByText } = render(h(TestComponent));
|
|
41
|
+
await waitFor(() => expect(getByTestId("ready").textContent).toBe("true"));
|
|
42
|
+
expect(getByTestId("roles").textContent).toBe("");
|
|
43
|
+
expect(getByTestId("caps").textContent).toBe("");
|
|
44
|
+
fireEvent.click(getByText("Refetch"));
|
|
45
|
+
await waitFor(() => expect(getByTestId("ready").textContent).toBe("true"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("derives roles and capabilities from localStorage user", async () => {
|
|
49
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: 1, role: "editor" }));
|
|
50
|
+
function TestComponent() {
|
|
51
|
+
const rbac = useRBAC({
|
|
52
|
+
userSource: { type: "localStorage", key: STORAGE_KEY },
|
|
53
|
+
roleDefinitions: ROLE_DEFS,
|
|
54
|
+
roleCapabilities: ROLE_CAPS,
|
|
55
|
+
});
|
|
56
|
+
return h("div", {},
|
|
57
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
58
|
+
h("span", { "data-testid": "caps" }, rbac.capabilities.sort().join(",")),
|
|
59
|
+
h("span", { "data-testid": "has-editor" }, String(rbac.hasRole("editor"))),
|
|
60
|
+
h("span", { "data-testid": "can-edit" }, String(rbac.can("posts:edit"))),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const { getByTestId } = render(h(TestComponent));
|
|
64
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toContain("editor"));
|
|
65
|
+
expect(getByTestId("roles").textContent).toContain("viewer");
|
|
66
|
+
expect(getByTestId("caps").textContent).toContain("posts:edit");
|
|
67
|
+
expect(getByTestId("has-editor").textContent).toBe("true");
|
|
68
|
+
expect(getByTestId("can-edit").textContent).toBe("true");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("admin gets wildcard capability", async () => {
|
|
72
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: 1, role: "admin" }));
|
|
73
|
+
function TestComponent() {
|
|
74
|
+
const rbac = useRBAC({
|
|
75
|
+
userSource: { type: "localStorage", key: STORAGE_KEY },
|
|
76
|
+
roleDefinitions: ROLE_DEFS,
|
|
77
|
+
roleCapabilities: ROLE_CAPS,
|
|
78
|
+
});
|
|
79
|
+
return h("div", {},
|
|
80
|
+
h("span", { "data-testid": "can-any" }, String(rbac.can("anything:foo"))),
|
|
81
|
+
h("span", { "data-testid": "has-admin" }, String(rbac.hasRole("admin"))),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const { getByTestId } = render(h(TestComponent));
|
|
85
|
+
await waitFor(() => expect(getByTestId("has-admin").textContent).toBe("true"));
|
|
86
|
+
expect(getByTestId("can-any").textContent).toBe("true");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("sessionStorage source works", async () => {
|
|
90
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: 2, role: "viewer" }));
|
|
91
|
+
function TestComponent() {
|
|
92
|
+
const rbac = useRBAC({
|
|
93
|
+
userSource: { type: "sessionStorage", key: STORAGE_KEY },
|
|
94
|
+
roleDefinitions: ROLE_DEFS,
|
|
95
|
+
roleCapabilities: ROLE_CAPS,
|
|
96
|
+
});
|
|
97
|
+
return h("div", {},
|
|
98
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
99
|
+
h("span", { "data-testid": "can-read" }, String(rbac.can("posts:read"))),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const { getByTestId } = render(h(TestComponent));
|
|
103
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toContain("viewer"));
|
|
104
|
+
expect(getByTestId("can-read").textContent).toBe("true");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("memory source works", async () => {
|
|
108
|
+
const user = { id: 3, role: "editor" };
|
|
109
|
+
function TestComponent() {
|
|
110
|
+
const rbac = useRBAC({
|
|
111
|
+
userSource: { type: "memory", getUser: () => user },
|
|
112
|
+
roleDefinitions: ROLE_DEFS,
|
|
113
|
+
roleCapabilities: ROLE_CAPS,
|
|
114
|
+
});
|
|
115
|
+
return h("div", {},
|
|
116
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
117
|
+
h("span", { "data-testid": "can-create" }, String(rbac.can("posts:create"))),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const { getByTestId } = render(h(TestComponent));
|
|
121
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toContain("editor"));
|
|
122
|
+
expect(getByTestId("can-create").textContent).toBe("true");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("custom source with explicit roles and capabilities", async () => {
|
|
126
|
+
function TestComponent() {
|
|
127
|
+
const rbac = useRBAC({
|
|
128
|
+
userSource: {
|
|
129
|
+
type: "custom",
|
|
130
|
+
getAuth: () => ({
|
|
131
|
+
user: { id: 1 },
|
|
132
|
+
roles: ["custom-role"],
|
|
133
|
+
capabilities: ["custom:read", "custom:write"],
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
roleDefinitions: ROLE_DEFS,
|
|
137
|
+
roleCapabilities: ROLE_CAPS,
|
|
138
|
+
});
|
|
139
|
+
return h("div", {},
|
|
140
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
141
|
+
h("span", { "data-testid": "caps" }, rbac.capabilities.join(",")),
|
|
142
|
+
h("span", { "data-testid": "can-write" }, String(rbac.can("custom:write"))),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const { getByTestId } = render(h(TestComponent));
|
|
146
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toBe("custom-role"));
|
|
147
|
+
expect(getByTestId("caps").textContent).toContain("custom:write");
|
|
148
|
+
expect(getByTestId("can-write").textContent).toBe("true");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("setUserInStorage updates localStorage and refetches when key matches", async () => {
|
|
152
|
+
function TestComponent() {
|
|
153
|
+
const rbac = useRBAC({
|
|
154
|
+
userSource: { type: "localStorage", key: STORAGE_KEY },
|
|
155
|
+
roleDefinitions: ROLE_DEFS,
|
|
156
|
+
roleCapabilities: ROLE_CAPS,
|
|
157
|
+
});
|
|
158
|
+
return h("div", {},
|
|
159
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
160
|
+
h("button", {
|
|
161
|
+
onClick: () => rbac.setUserInStorage({ id: 1, role: "editor" }, "localStorage", STORAGE_KEY),
|
|
162
|
+
}, "Login as editor"),
|
|
163
|
+
h("button", { onClick: () => rbac.setUserInStorage(null, "localStorage", STORAGE_KEY) }, "Logout"),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const { getByTestId, getByText } = render(h(TestComponent));
|
|
167
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toBe(""));
|
|
168
|
+
fireEvent.click(getByText("Login as editor"));
|
|
169
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toContain("editor"));
|
|
170
|
+
fireEvent.click(getByText("Logout"));
|
|
171
|
+
await waitFor(() => expect(getByTestId("roles").textContent).toBe(""));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("api source and error handling", async () => {
|
|
175
|
+
const fetchMock = vi.fn().mockRejectedValue(new Error("Network error"));
|
|
176
|
+
function TestComponent() {
|
|
177
|
+
const rbac = useRBAC({
|
|
178
|
+
userSource: { type: "api", fetch: fetchMock },
|
|
179
|
+
roleDefinitions: ROLE_DEFS,
|
|
180
|
+
roleCapabilities: ROLE_CAPS,
|
|
181
|
+
});
|
|
182
|
+
return h("div", {},
|
|
183
|
+
h("span", { "data-testid": "ready" }, String(rbac.isReady)),
|
|
184
|
+
h("span", { "data-testid": "error" }, rbac.error?.message ?? ""),
|
|
185
|
+
h("span", { "data-testid": "roles" }, rbac.roles.join(",")),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const { getByTestId } = render(h(TestComponent));
|
|
189
|
+
await waitFor(() => expect(getByTestId("ready").textContent).toBe("true"));
|
|
190
|
+
expect(getByTestId("error").textContent).toBe("Network error");
|
|
191
|
+
expect(getByTestId("roles").textContent).toBe("");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("capabilitiesOverride from localStorage overrides role-derived capabilities", async () => {
|
|
195
|
+
const capsKey = "rbac-caps";
|
|
196
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: 1, role: "viewer" }));
|
|
197
|
+
localStorage.setItem(capsKey, JSON.stringify(["posts:read", "posts:edit"]));
|
|
198
|
+
function TestComponent() {
|
|
199
|
+
const rbac = useRBAC({
|
|
200
|
+
userSource: { type: "localStorage", key: STORAGE_KEY },
|
|
201
|
+
roleDefinitions: ROLE_DEFS,
|
|
202
|
+
roleCapabilities: ROLE_CAPS,
|
|
203
|
+
capabilitiesOverride: { type: "localStorage", key: capsKey },
|
|
204
|
+
});
|
|
205
|
+
return h("div", {},
|
|
206
|
+
h("span", { "data-testid": "can-edit" }, String(rbac.can("posts:edit"))),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const { getByTestId } = render(h(TestComponent));
|
|
210
|
+
await waitFor(() => expect(getByTestId("can-edit").textContent).toBe("true"));
|
|
211
|
+
});
|
|
212
|
+
});
|