@classic-homes/auth 0.1.55 → 1.0.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/README.md CHANGED
@@ -37,6 +37,12 @@ initAuth({
37
37
  setItem: (key, value) => localStorage.setItem(key, value),
38
38
  removeItem: (key) => localStorage.removeItem(key),
39
39
  },
40
+ // ⚠️ SECURITY: localStorage (also the default when `storage` is omitted)
41
+ // persists the long-lived refresh token where any XSS can read it.
42
+ // Safer options exported from '@classic-homes/auth/core':
43
+ // storage: createSessionStorage() // per-tab, cleared on close
44
+ // storage: createMemoryStorage() // never persisted, lost on reload
45
+ // or keep tokens out of JS entirely with an httpOnly-cookie flow.
40
46
  // SSO configuration (optional)
41
47
  sso: {
42
48
  enabled: true,
@@ -196,6 +202,74 @@ export function load({ url }) {
196
202
  }
197
203
  ```
198
204
 
205
+ ## Roles
206
+
207
+ A user holds a **set** of roles (`user.roles`). Their permissions are the union
208
+ across all of them, so a user's access is broader than any single role.
209
+
210
+ ```typescript
211
+ import { authStore } from '@classic-homes/auth/svelte';
212
+
213
+ // A manager who is also an agent
214
+ authStore.user?.roles; // ['manager', 'agent']
215
+
216
+ authStore.hasRole('manager'); // true
217
+ authStore.hasRole('agent'); // true — secondary roles count
218
+ authStore.hasAnyRole(['admin', 'agent']); // true
219
+ authStore.hasAllRoles(['manager', 'agent']); // true
220
+ ```
221
+
222
+ **Role checks are exact membership.** Holding `admin` does not imply `agent`:
223
+
224
+ ```typescript
225
+ authStore.user?.roles; // ['admin']
226
+ authStore.hasRole('agent'); // false
227
+ ```
228
+
229
+ This mirrors the API, so the UI can't offer something the server will reject.
230
+ To let admins through as well, list the roles you accept:
231
+ `checkAuth({ roles: ['admin', 'agent'] })`.
232
+
233
+ ### Displaying a role
234
+
235
+ `computePrimaryRole` resolves the highest-tier role for display. It's a label,
236
+ not a capability — never gate on it:
237
+
238
+ ```typescript
239
+ import { computePrimaryRole, getEffectiveLevel } from '@classic-homes/auth/core';
240
+
241
+ computePrimaryRole(['user', 'admin']); // 'admin'
242
+ computePrimaryRole([]); // 'guest'
243
+ getEffectiveLevel(['user', 'manager']); // 3
244
+
245
+ // Or format the whole set
246
+ import { formatUserRoles } from '@classic-homes/auth/core';
247
+ formatUserRoles(user); // 'Manager, Agent'
248
+ formatUserRoles(user, { max: 1 }); // 'Manager +1 more'
249
+ ```
250
+
251
+ ### Managing another user's roles
252
+
253
+ Requires `users:read` to list and `users:update` to change:
254
+
255
+ ```typescript
256
+ import { authApi } from '@classic-homes/auth/core';
257
+
258
+ const { roles, primaryRole } = await authApi.getUserRoles(userId);
259
+
260
+ await authApi.grantUserRole(userId, 'agent'); // idempotent
261
+ await authApi.revokeUserRole(userId, 'agent');
262
+ await authApi.setUserRoles(userId, ['manager', 'agent']); // replaces the set
263
+ ```
264
+
265
+ Two server rules to design around:
266
+
267
+ - **A role change doesn't affect that user's live session.** Permissions are
268
+ computed when a token is minted, so a grant takes effect on their next token
269
+ refresh — not their next request.
270
+ - The server refuses to remove a user's **last** role, or to grant a role at or
271
+ above the acting user's own level. Both come back as errors.
272
+
199
273
  ## API Reference
200
274
 
201
275
  ### Core Exports
@@ -225,8 +299,20 @@ import {
225
299
  isTokenExpired,
226
300
  getTokenRemainingTime,
227
301
 
302
+ // Roles
303
+ ROLE_NAMES,
304
+ ROLE_HIERARCHY,
305
+ getUserRoles,
306
+ computePrimaryRole,
307
+ getEffectiveLevel,
308
+ hasRole,
309
+ hasAnyRole,
310
+ hasAllRoles,
311
+
228
312
  // Types
229
313
  type User,
314
+ type UserRolesResponse,
315
+ type RoleName,
230
316
  type AuthState,
231
317
  type LoginCredentials,
232
318
  type LoginResponse,
@@ -642,7 +728,7 @@ import {
642
728
  } from '@classic-homes/auth/testing';
643
729
 
644
730
  // Use pre-defined users
645
- expect(mockUser.role).toBe('user');
731
+ expect(mockUser.roles).toEqual(['user']);
646
732
  expect(mockAdminUser.permissions).toContain('manage:system');
647
733
 
648
734
  // Create custom users
@@ -0,0 +1,3 @@
1
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-QBTMNWJH.js';
2
+ import './chunk-2N2MDCWL.js';
3
+ import './chunk-O47YKG2F.js';
@@ -1,4 +1,4 @@
1
- import { getConfig, getStorage, getFetch, isValidRedirectUrl } from './chunk-EM7PUNZB.js';
1
+ import { getConfig, getStorage, getFetch, isValidRedirectUrl } from './chunk-O47YKG2F.js';
2
2
 
3
3
  // src/core/client.ts
4
4
  var isRefreshing = false;
@@ -70,9 +70,8 @@ function updateStoredTokens(accessToken, refreshToken) {
70
70
  }
71
71
  parsed.accessToken = accessToken;
72
72
  parsed.refreshToken = refreshToken;
73
- parsed._version = (parsed._version || 0) + 1;
74
73
  storage.setItem(storageKey, JSON.stringify(parsed));
75
- import('./auth.svelte-BBLX2MKB.js').then(({ authStore }) => {
74
+ import('./auth.svelte-J62VV4TA.js').then(({ authStore }) => {
76
75
  authStore.updateTokens(accessToken, refreshToken);
77
76
  }).catch(() => {
78
77
  });
@@ -91,13 +90,17 @@ async function refreshAccessToken() {
91
90
  if (!refreshToken) {
92
91
  return false;
93
92
  }
93
+ const timeoutMs = config.requestTimeout ?? 3e4;
94
+ const controller = new AbortController();
95
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
94
96
  try {
95
97
  const response = await fetchFn(`${config.baseUrl}/auth/refresh`, {
96
98
  method: "POST",
97
99
  headers: {
98
100
  "Content-Type": "application/json"
99
101
  },
100
- body: JSON.stringify({ refreshToken })
102
+ body: JSON.stringify({ refreshToken }),
103
+ signal: controller.signal
101
104
  });
102
105
  if (response.ok) {
103
106
  let apiResponse;
@@ -126,6 +129,8 @@ async function refreshAccessToken() {
126
129
  }
127
130
  } catch (error) {
128
131
  config.onAuthError?.(error instanceof Error ? error : new Error("Token refresh failed"));
132
+ } finally {
133
+ clearTimeout(timeoutId);
129
134
  }
130
135
  return false;
131
136
  }
@@ -141,7 +146,10 @@ function extractData(response) {
141
146
  }
142
147
  return response;
143
148
  }
144
- async function apiRequest(endpoint, options = {}) {
149
+ function apiRequest(endpoint, options = {}) {
150
+ return apiRequestInternal(endpoint, options, false);
151
+ }
152
+ async function apiRequestInternal(endpoint, options, isRetry) {
145
153
  const { authenticated = false, customFetch, body, timeout, signal, ...fetchOptions } = options;
146
154
  const config = getConfig();
147
155
  const fetchFn = customFetch ?? getFetch();
@@ -179,12 +187,21 @@ async function apiRequest(endpoint, options = {}) {
179
187
  });
180
188
  clearTimeout(timeoutId);
181
189
  if (response.status === 401 && authenticated && typeof window !== "undefined") {
190
+ if (isRetry) {
191
+ clearStoredAuth();
192
+ config.onLogout?.();
193
+ throw new Error("Authentication failed. Please sign in again.");
194
+ }
182
195
  if (!isRefreshing) {
183
196
  isRefreshing = true;
184
- const refreshed = await refreshAccessToken();
185
- isRefreshing = false;
197
+ let refreshed = false;
198
+ try {
199
+ refreshed = await refreshAccessToken();
200
+ } finally {
201
+ isRefreshing = false;
202
+ }
186
203
  if (refreshed) {
187
- return apiRequest(endpoint, options);
204
+ return apiRequestInternal(endpoint, options, true);
188
205
  } else {
189
206
  clearStoredAuth();
190
207
  config.onLogout?.();
@@ -194,21 +211,29 @@ async function apiRequest(endpoint, options = {}) {
194
211
  const REFRESH_TIMEOUT_MS = 3e4;
195
212
  return new Promise((resolve, reject) => {
196
213
  let settled = false;
197
- let unsubscribe = null;
198
- const timeoutId2 = setTimeout(() => {
214
+ const refreshTimeoutId = setTimeout(() => {
199
215
  if (!settled) {
200
216
  settled = true;
201
217
  unsubscribe?.();
202
218
  reject(new Error("Token refresh timed out. Please sign in again."));
203
219
  }
204
220
  }, REFRESH_TIMEOUT_MS);
205
- unsubscribe = subscribeTokenRefresh(() => {
221
+ let unsubscribe = null;
222
+ try {
223
+ unsubscribe = subscribeTokenRefresh(() => {
224
+ if (!settled) {
225
+ settled = true;
226
+ clearTimeout(refreshTimeoutId);
227
+ apiRequestInternal(endpoint, options, true).then(resolve).catch(reject);
228
+ }
229
+ });
230
+ } catch (subscribeError) {
231
+ clearTimeout(refreshTimeoutId);
206
232
  if (!settled) {
207
233
  settled = true;
208
- clearTimeout(timeoutId2);
209
- apiRequest(endpoint, options).then(resolve).catch(reject);
234
+ reject(subscribeError);
210
235
  }
211
- });
236
+ }
212
237
  });
213
238
  }
214
239
  }
@@ -294,6 +319,23 @@ var api = {
294
319
  };
295
320
 
296
321
  // src/core/api.ts
322
+ async function fetchWithTimeout(url, init) {
323
+ const config = getConfig();
324
+ const fetchFn = config.fetch ?? fetch;
325
+ const timeoutMs = config.requestTimeout ?? 3e4;
326
+ const controller = new AbortController();
327
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
328
+ try {
329
+ return await fetchFn(url, { ...init, signal: controller.signal });
330
+ } catch (error) {
331
+ if (error instanceof DOMException && error.name === "AbortError") {
332
+ throw new Error(`Request timed out after ${timeoutMs}ms`);
333
+ }
334
+ throw error;
335
+ } finally {
336
+ clearTimeout(timeoutId);
337
+ }
338
+ }
297
339
  var authApi = {
298
340
  // ============================================================================
299
341
  // Authentication
@@ -318,8 +360,9 @@ var authApi = {
318
360
  return extractData(response);
319
361
  } catch (error) {
320
362
  const config = getConfig();
321
- config.onAuthError?.(error instanceof Error ? error : new Error("Logout API call failed"));
322
- return { success: true };
363
+ const err = error instanceof Error ? error : new Error("Logout API call failed");
364
+ config.onAuthError?.(err);
365
+ return { success: true, serverError: err.message };
323
366
  }
324
367
  },
325
368
  /**
@@ -515,13 +558,12 @@ var authApi = {
515
558
  */
516
559
  async verifyMFAChallenge(data) {
517
560
  const config = getConfig();
518
- const fetchFn = config.fetch ?? fetch;
519
561
  const requestBody = {
520
562
  code: data.code,
521
563
  type: data.method === "backup" ? "backup" : "totp",
522
564
  trustDevice: data.trustDevice
523
565
  };
524
- const response = await fetchFn(`${config.baseUrl}/auth/mfa/challenge`, {
566
+ const response = await fetchWithTimeout(`${config.baseUrl}/auth/mfa/challenge`, {
525
567
  method: "POST",
526
568
  headers: {
527
569
  "Content-Type": "application/json",
@@ -650,8 +692,7 @@ var authApi = {
650
692
  throw new Error("Not authenticated");
651
693
  }
652
694
  const config = getConfig();
653
- const fetchFn = config.fetch ?? fetch;
654
- const response = await fetchFn(`${config.baseUrl}/auth/sso/link`, {
695
+ const response = await fetchWithTimeout(`${config.baseUrl}/auth/sso/link`, {
655
696
  method: "POST",
656
697
  headers: {
657
698
  "Content-Type": "application/json",
@@ -687,6 +728,75 @@ var authApi = {
687
728
  if (params?.type) queryParams.append("type", params.type);
688
729
  const response = await api.get(`/auth/security/events?${queryParams.toString()}`, true, customFetch);
689
730
  return extractData(response);
731
+ },
732
+ // ============================================================================
733
+ // User Roles
734
+ // ============================================================================
735
+ //
736
+ // Managing another user's roles. Requires `users:read` to list and
737
+ // `users:update` to change.
738
+ //
739
+ // Two server rules worth designing around rather than discovering:
740
+ // - A role change does NOT affect that user's live session. Permissions are
741
+ // computed when a token is minted, so the grant takes effect on their next
742
+ // token refresh — not on their next request.
743
+ // - The server refuses a change that would remove a user's last role, or
744
+ // that would grant a role at or above the acting user's own level. Both
745
+ // come back as errors, so surface them rather than assuming success.
746
+ /**
747
+ * List a user's roles.
748
+ *
749
+ * @example
750
+ * ```typescript
751
+ * const { roles, primaryRole } = await authApi.getUserRoles(userId);
752
+ * ```
753
+ */
754
+ async getUserRoles(userId, customFetch) {
755
+ const response = await api.get(
756
+ `/users/${userId}/roles`,
757
+ true,
758
+ customFetch
759
+ );
760
+ return extractData(response);
761
+ },
762
+ /**
763
+ * Grant a role to a user. Idempotent — granting a role they already hold
764
+ * succeeds and returns the unchanged set.
765
+ *
766
+ * @returns The user's full role set after the grant.
767
+ */
768
+ async grantUserRole(userId, role) {
769
+ const response = await api.put(
770
+ `/users/${userId}/roles/${role}`,
771
+ {},
772
+ true
773
+ );
774
+ return extractData(response);
775
+ },
776
+ /**
777
+ * Revoke a role from a user.
778
+ *
779
+ * Rejected by the server if it would leave the user with no roles.
780
+ *
781
+ * @returns The user's full role set after the revoke.
782
+ */
783
+ async revokeUserRole(userId, role) {
784
+ const response = await api.delete(
785
+ `/users/${userId}/roles/${role}`,
786
+ true
787
+ );
788
+ return extractData(response);
789
+ },
790
+ /**
791
+ * Replace a user's entire role set.
792
+ *
793
+ * Roles absent from `roles` are revoked, so read-modify-write against
794
+ * {@link getUserRoles} rather than passing a set built from stale state.
795
+ * Use {@link grantUserRole} / {@link revokeUserRole} for additive changes.
796
+ */
797
+ async setUserRoles(userId, roles) {
798
+ const response = await api.put(`/users/${userId}`, { roles }, true);
799
+ return extractData(response);
690
800
  }
691
801
  };
692
802
 
@@ -1,6 +1,6 @@
1
- import { authService } from './chunk-TUHQ4FHC.js';
2
- import { authStore, currentUser, isAuthenticated, authActions } from './chunk-BK34CVH5.js';
3
- import { isInitialized, initAuth } from './chunk-EM7PUNZB.js';
1
+ import { authService } from './chunk-VVREZ25E.js';
2
+ import { authStore, currentUser, isAuthenticated, authActions } from './chunk-QBTMNWJH.js';
3
+ import { isInitialized, initAuth, getUserRoles } from './chunk-O47YKG2F.js';
4
4
 
5
5
  // src/svelte/guards/auth-guard.ts
6
6
  function checkAuth(options = {}) {
@@ -88,11 +88,13 @@ function filterByAccess(items, user, options = {}) {
88
88
  const {
89
89
  requireAllRoles = false,
90
90
  requireAllPermissions = false,
91
- hideUnrestrictedForGuests = false
91
+ hideUnrestrictedForGuests = false,
92
+ childrenKey,
93
+ pruneEmptyParents = true
92
94
  } = options;
93
- const userRoles = user?.roles || (user?.role ? [user.role] : []);
95
+ const userRoles = getUserRoles(user);
94
96
  const userPerms = user?.permissions || [];
95
- return items.filter((item) => {
97
+ function isVisible(item) {
96
98
  const hasRoleRestriction = item.roles && item.roles.length > 0;
97
99
  const hasPermRestriction = item.permissions && item.permissions.length > 0;
98
100
  if (!hasRoleRestriction && !hasPermRestriction) {
@@ -117,7 +119,28 @@ function filterByAccess(items, user, options = {}) {
117
119
  }
118
120
  }
119
121
  return true;
120
- });
122
+ }
123
+ function filterLevel(level) {
124
+ const visible = level.filter(isVisible);
125
+ if (!childrenKey) {
126
+ return visible;
127
+ }
128
+ const result = [];
129
+ for (const item of visible) {
130
+ const children = item[childrenKey];
131
+ if (!Array.isArray(children)) {
132
+ result.push(item);
133
+ continue;
134
+ }
135
+ const filteredChildren = filterLevel(children);
136
+ if (pruneEmptyParents && children.length > 0 && filteredChildren.length === 0 && !item.href) {
137
+ continue;
138
+ }
139
+ result.push({ ...item, [childrenKey]: filteredChildren });
140
+ }
141
+ return result;
142
+ }
143
+ return filterLevel(items);
121
144
  }
122
145
  function filterNavSections(sections, user, options = {}) {
123
146
  return sections.map((section) => ({
@@ -36,6 +36,34 @@ function getDefaultStorage() {
36
36
  }
37
37
  };
38
38
  }
39
+ function createSessionStorage() {
40
+ if (typeof window !== "undefined" && window.sessionStorage) {
41
+ return {
42
+ getItem: (key) => sessionStorage.getItem(key),
43
+ setItem: (key, value) => sessionStorage.setItem(key, value),
44
+ removeItem: (key) => sessionStorage.removeItem(key)
45
+ };
46
+ }
47
+ return {
48
+ getItem: () => null,
49
+ setItem: () => {
50
+ },
51
+ removeItem: () => {
52
+ }
53
+ };
54
+ }
55
+ function createMemoryStorage() {
56
+ const store = /* @__PURE__ */ new Map();
57
+ return {
58
+ getItem: (key) => store.get(key) ?? null,
59
+ setItem: (key, value) => {
60
+ store.set(key, value);
61
+ },
62
+ removeItem: (key) => {
63
+ store.delete(key);
64
+ }
65
+ };
66
+ }
39
67
  function getStorage() {
40
68
  const cfg = getConfig();
41
69
  return cfg.storage ?? getDefaultStorage();
@@ -91,7 +119,8 @@ function isTokenExpired(token) {
91
119
  if (!payload || !payload.exp) {
92
120
  return true;
93
121
  }
94
- return payload.exp * 1e3 < Date.now();
122
+ const CLOCK_SKEW_MS = 3e4;
123
+ return payload.exp * 1e3 < Date.now() - CLOCK_SKEW_MS;
95
124
  }
96
125
  function getTokenRemainingTime(token) {
97
126
  const payload = decodeJWT(token);
@@ -122,4 +151,60 @@ function extractClaims(token, claims) {
122
151
  return result;
123
152
  }
124
153
 
125
- export { decodeJWT, extractClaims, getConfig, getDefaultStorage, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, initAuth, isInitialized, isTokenExpired, isValidRedirectUrl, resetConfig };
154
+ // src/core/roles.ts
155
+ var ROLE_NAMES = [
156
+ "admin",
157
+ "manager",
158
+ "agent",
159
+ "builder",
160
+ "customer_service",
161
+ "demo",
162
+ "user",
163
+ "vendor",
164
+ "customer",
165
+ "subcontractor",
166
+ "guest"
167
+ ];
168
+ var ROLE_HIERARCHY = {
169
+ guest: 0,
170
+ user: 1,
171
+ vendor: 1,
172
+ customer: 1,
173
+ subcontractor: 1,
174
+ agent: 2,
175
+ builder: 2,
176
+ customer_service: 2,
177
+ demo: 2,
178
+ manager: 3,
179
+ admin: 4
180
+ };
181
+ function getUserRoles(user) {
182
+ return user?.roles ?? [];
183
+ }
184
+ function computePrimaryRole(roles) {
185
+ const held = new Set(roles);
186
+ for (const role of ROLE_NAMES) {
187
+ if (held.has(role)) return role;
188
+ }
189
+ return "guest";
190
+ }
191
+ function getEffectiveLevel(roles) {
192
+ let max = 0;
193
+ for (const role of roles) {
194
+ max = Math.max(max, ROLE_HIERARCHY[role] ?? 0);
195
+ }
196
+ return max;
197
+ }
198
+ function hasRole(user, role) {
199
+ return getUserRoles(user).includes(role);
200
+ }
201
+ function hasAnyRole(user, roles) {
202
+ const held = getUserRoles(user);
203
+ return roles.some((role) => held.includes(role));
204
+ }
205
+ function hasAllRoles(user, roles) {
206
+ const held = getUserRoles(user);
207
+ return roles.every((role) => held.includes(role));
208
+ }
209
+
210
+ export { ROLE_HIERARCHY, ROLE_NAMES, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isInitialized, isTokenExpired, isValidRedirectUrl, resetConfig };
@@ -1,7 +1,8 @@
1
- import { authApi } from './chunk-GRQL5FSH.js';
2
- import { decodeJWT, isInitialized, getConfig, getStorage, getDefaultStorage } from './chunk-EM7PUNZB.js';
1
+ import { authApi } from './chunk-2N2MDCWL.js';
2
+ import { isInitialized, getConfig, hasRole, hasAnyRole, hasAllRoles, decodeJWT, getStorage, getDefaultStorage } from './chunk-O47YKG2F.js';
3
3
 
4
4
  // src/svelte/stores/auth.svelte.ts
5
+ var STORAGE_VERSION = 2;
5
6
  function getStorageKey() {
6
7
  return isInitialized() ? getConfig().storageKey ?? "classic_auth" : "classic_auth";
7
8
  }
@@ -11,32 +12,37 @@ function getSessionTokenKey() {
11
12
  function getStorageAdapter() {
12
13
  return isInitialized() ? getStorage() : getDefaultStorage();
13
14
  }
15
+ function emptyAuthState() {
16
+ return {
17
+ accessToken: null,
18
+ refreshToken: null,
19
+ user: null,
20
+ isAuthenticated: false
21
+ };
22
+ }
14
23
  function loadAuthFromStorage() {
15
24
  try {
16
25
  const storage = getStorageAdapter();
17
26
  const data = storage.getItem(getStorageKey());
18
27
  if (!data) {
19
- return {
20
- accessToken: null,
21
- refreshToken: null,
22
- user: null,
23
- isAuthenticated: false
24
- };
28
+ return emptyAuthState();
25
29
  }
26
30
  const parsed = JSON.parse(data);
31
+ if (parsed.version !== STORAGE_VERSION) {
32
+ storage.removeItem(getStorageKey());
33
+ return emptyAuthState();
34
+ }
35
+ const accessToken = parsed.accessToken ?? null;
27
36
  return {
28
- accessToken: parsed.accessToken ?? null,
37
+ accessToken,
29
38
  refreshToken: parsed.refreshToken ?? null,
30
- user: parsed.user ?? null,
31
- isAuthenticated: !!parsed.accessToken && !!parsed.user
39
+ // Normalize on read as well as write: a payload can be stale, hand-written,
40
+ // or predate a field, and every role predicate downstream reads `roles`.
41
+ user: parsed.user ? normalizeUser(parsed.user, accessToken) : null,
42
+ isAuthenticated: !!accessToken && !!parsed.user
32
43
  };
33
44
  } catch {
34
- return {
35
- accessToken: null,
36
- refreshToken: null,
37
- user: null,
38
- isAuthenticated: false
39
- };
45
+ return emptyAuthState();
40
46
  }
41
47
  }
42
48
  function saveAuthToStorage(state) {
@@ -46,11 +52,22 @@ function saveAuthToStorage(state) {
46
52
  if (!state.accessToken) {
47
53
  storage.removeItem(key);
48
54
  } else {
49
- storage.setItem(key, JSON.stringify(state));
55
+ storage.setItem(key, JSON.stringify({ ...state, version: STORAGE_VERSION }));
50
56
  }
51
57
  } catch {
52
58
  }
53
59
  }
60
+ function normalizeUser(user, accessToken) {
61
+ const jwtPayload = accessToken ? decodeJWT(accessToken) : null;
62
+ return {
63
+ ...user,
64
+ roles: firstNonEmpty(jwtPayload?.roles, user.roles),
65
+ permissions: firstNonEmpty(jwtPayload?.permissions, user.permissions)
66
+ };
67
+ }
68
+ function firstNonEmpty(...candidates) {
69
+ return candidates.find((candidate) => candidate && candidate.length > 0) ?? [];
70
+ }
54
71
  var AuthStore = class {
55
72
  constructor() {
56
73
  this.subscribers = /* @__PURE__ */ new Set();
@@ -90,12 +107,7 @@ var AuthStore = class {
90
107
  * Set auth data after successful login.
91
108
  */
92
109
  setAuth(accessToken, refreshToken, user, sessionToken) {
93
- const jwtPayload = decodeJWT(accessToken);
94
- const userWithPermissions = {
95
- ...user,
96
- permissions: user.permissions || jwtPayload?.permissions || [],
97
- roles: user.roles || jwtPayload?.roles || (user.role ? [user.role] : [])
98
- };
110
+ const userWithPermissions = normalizeUser(user, accessToken);
99
111
  this.state = {
100
112
  accessToken,
101
113
  refreshToken,
@@ -119,12 +131,7 @@ var AuthStore = class {
119
131
  * Update tokens (e.g., after refresh).
120
132
  */
121
133
  updateTokens(accessToken, refreshToken) {
122
- const jwtPayload = decodeJWT(accessToken);
123
- const updatedUser = this.state.user ? {
124
- ...this.state.user,
125
- permissions: jwtPayload?.permissions || this.state.user.permissions || [],
126
- roles: jwtPayload?.roles || this.state.user.roles || (this.state.user.role ? [this.state.user.role] : [])
127
- } : null;
134
+ const updatedUser = this.state.user ? normalizeUser(this.state.user, accessToken) : null;
128
135
  this.state = {
129
136
  ...this.state,
130
137
  accessToken,
@@ -141,12 +148,7 @@ var AuthStore = class {
141
148
  * Update user data (e.g., after profile update).
142
149
  */
143
150
  updateUser(user) {
144
- const jwtPayload = this.state.accessToken ? decodeJWT(this.state.accessToken) : null;
145
- const updatedUser = {
146
- ...user,
147
- permissions: user.permissions || jwtPayload?.permissions || [],
148
- roles: user.roles || jwtPayload?.roles || (user.role ? [user.role] : [])
149
- };
151
+ const updatedUser = normalizeUser(user, this.state.accessToken);
150
152
  this.state = {
151
153
  ...this.state,
152
154
  user: updatedUser
@@ -235,22 +237,24 @@ var AuthStore = class {
235
237
  return this.state.user?.permissions?.includes(permission) ?? false;
236
238
  }
237
239
  /**
238
- * Check if user has a specific role.
240
+ * Check if the user holds a specific role.
241
+ *
242
+ * Exact membership — holding `admin` does not imply `agent`.
239
243
  */
240
244
  hasRole(role) {
241
- return this.state.user?.roles?.includes(role) ?? false;
245
+ return hasRole(this.state.user, role);
242
246
  }
243
247
  /**
244
- * Check if user has any of the specified roles.
248
+ * Check if the user holds any of the specified roles.
245
249
  */
246
250
  hasAnyRole(roles) {
247
- return roles.some((role) => this.state.user?.roles?.includes(role)) ?? false;
251
+ return hasAnyRole(this.state.user, roles);
248
252
  }
249
253
  /**
250
- * Check if user has all of the specified roles.
254
+ * Check if the user holds all of the specified roles.
251
255
  */
252
256
  hasAllRoles(roles) {
253
- return roles.every((role) => this.state.user?.roles?.includes(role)) ?? false;
257
+ return hasAllRoles(this.state.user, roles);
254
258
  }
255
259
  /**
256
260
  * Check if user has any of the specified permissions.