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/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
+ });