@classic-homes/auth 0.1.56 → 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/README.md CHANGED
@@ -202,6 +202,74 @@ export function load({ url }) {
202
202
  }
203
203
  ```
204
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
+
205
273
  ## API Reference
206
274
 
207
275
  ### Core Exports
@@ -231,8 +299,20 @@ import {
231
299
  isTokenExpired,
232
300
  getTokenRemainingTime,
233
301
 
302
+ // Roles
303
+ ROLE_NAMES,
304
+ ROLE_HIERARCHY,
305
+ getUserRoles,
306
+ computePrimaryRole,
307
+ getEffectiveLevel,
308
+ hasRole,
309
+ hasAnyRole,
310
+ hasAllRoles,
311
+
234
312
  // Types
235
313
  type User,
314
+ type UserRolesResponse,
315
+ type RoleName,
236
316
  type AuthState,
237
317
  type LoginCredentials,
238
318
  type LoginResponse,
@@ -648,7 +728,7 @@ import {
648
728
  } from '@classic-homes/auth/testing';
649
729
 
650
730
  // Use pre-defined users
651
- expect(mockUser.role).toBe('user');
731
+ expect(mockUser.roles).toEqual(['user']);
652
732
  expect(mockAdminUser.permissions).toContain('manage:system');
653
733
 
654
734
  // Create custom users
@@ -0,0 +1,3 @@
1
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-GWOTYGQI.js';
2
+ import './chunk-7DW44JGU.js';
3
+ import './chunk-S4TOKARB.js';
@@ -1,6 +1,6 @@
1
- import { authService } from './chunk-MPPV5S7S.js';
2
- import { authStore, currentUser, isAuthenticated, authActions } from './chunk-CPFM7JEI.js';
3
- import { isInitialized, initAuth } from './chunk-6NTZVDLW.js';
1
+ import { authService } from './chunk-LIRO3PU2.js';
2
+ import { authStore, currentUser, isAuthenticated, authActions } from './chunk-GWOTYGQI.js';
3
+ import { isInitialized, initAuth, getUserRoles } from './chunk-S4TOKARB.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) => ({
@@ -1,4 +1,4 @@
1
- import { getConfig, getStorage, getFetch, isValidRedirectUrl } from './chunk-6NTZVDLW.js';
1
+ import { getConfig, getStorage, getFetch, isValidRedirectUrl } from './chunk-S4TOKARB.js';
2
2
 
3
3
  // src/core/client.ts
4
4
  var isRefreshing = false;
@@ -14,17 +14,23 @@ function subscribeTokenRefresh(cb) {
14
14
  if (idx > -1) refreshSubscribers.splice(idx, 1);
15
15
  };
16
16
  }
17
- function onTokenRefreshed(token) {
17
+ function flushRefreshSubscribers(token, terminal = false) {
18
18
  const subs = refreshSubscribers;
19
19
  refreshSubscribers = [];
20
20
  subs.forEach((cb) => {
21
21
  try {
22
- cb(token);
22
+ cb(token, terminal);
23
23
  } catch (e) {
24
24
  console.error("Token refresh subscriber error:", e);
25
25
  }
26
26
  });
27
27
  }
28
+ function onTokenRefreshed(token) {
29
+ flushRefreshSubscribers(token);
30
+ }
31
+ function onTokenRefreshFailed(terminal) {
32
+ flushRefreshSubscribers(null, terminal);
33
+ }
28
34
  function getAccessToken() {
29
35
  const config = getConfig();
30
36
  const storage = getStorage();
@@ -71,7 +77,7 @@ function updateStoredTokens(accessToken, refreshToken) {
71
77
  parsed.accessToken = accessToken;
72
78
  parsed.refreshToken = refreshToken;
73
79
  storage.setItem(storageKey, JSON.stringify(parsed));
74
- import('./auth.svelte-WPCYPHEE.js').then(({ authStore }) => {
80
+ import('./auth.svelte-FUE2KVI4.js').then(({ authStore }) => {
75
81
  authStore.updateTokens(accessToken, refreshToken);
76
82
  }).catch(() => {
77
83
  });
@@ -83,12 +89,21 @@ function clearStoredAuth() {
83
89
  storage.removeItem(config.storageKey ?? "classic_auth");
84
90
  storage.removeItem(getSessionTokenKey());
85
91
  }
92
+ async function expireSession() {
93
+ clearStoredAuth();
94
+ try {
95
+ const { authStore } = await import('./auth.svelte-FUE2KVI4.js');
96
+ authStore.handleSessionExpired();
97
+ } catch {
98
+ }
99
+ getConfig().onLogout?.();
100
+ }
86
101
  async function refreshAccessToken() {
87
102
  const refreshToken = getRefreshToken();
88
103
  const config = getConfig();
89
104
  const fetchFn = getFetch();
90
105
  if (!refreshToken) {
91
- return false;
106
+ return "rejected";
92
107
  }
93
108
  const timeoutMs = config.requestTimeout ?? 3e4;
94
109
  const controller = new AbortController();
@@ -109,13 +124,13 @@ async function refreshAccessToken() {
109
124
  try {
110
125
  apiResponse = await response.json();
111
126
  } catch {
112
- return false;
127
+ return "unavailable";
113
128
  }
114
129
  } else {
115
130
  try {
116
131
  apiResponse = await response.json();
117
132
  } catch {
118
- return false;
133
+ return "unavailable";
119
134
  }
120
135
  }
121
136
  const data = apiResponse?.data;
@@ -124,15 +139,20 @@ async function refreshAccessToken() {
124
139
  const newRefreshToken = tokens.refreshToken ?? refreshToken;
125
140
  updateStoredTokens(tokens.accessToken, newRefreshToken);
126
141
  onTokenRefreshed(tokens.accessToken);
127
- return true;
142
+ return "refreshed";
128
143
  }
144
+ return "unavailable";
129
145
  }
146
+ if (response.status >= 500) {
147
+ return "unavailable";
148
+ }
149
+ return "rejected";
130
150
  } catch (error) {
131
151
  config.onAuthError?.(error instanceof Error ? error : new Error("Token refresh failed"));
152
+ return "unavailable";
132
153
  } finally {
133
154
  clearTimeout(timeoutId);
134
155
  }
135
- return false;
136
156
  }
137
157
  function extractData(response) {
138
158
  if (response && typeof response === "object" && "data" in response) {
@@ -188,25 +208,27 @@ async function apiRequestInternal(endpoint, options, isRetry) {
188
208
  clearTimeout(timeoutId);
189
209
  if (response.status === 401 && authenticated && typeof window !== "undefined") {
190
210
  if (isRetry) {
191
- clearStoredAuth();
192
- config.onLogout?.();
211
+ await expireSession();
193
212
  throw new Error("Authentication failed. Please sign in again.");
194
213
  }
195
214
  if (!isRefreshing) {
196
215
  isRefreshing = true;
197
- let refreshed = false;
216
+ let refreshResult = "unavailable";
198
217
  try {
199
- refreshed = await refreshAccessToken();
218
+ refreshResult = await refreshAccessToken();
200
219
  } finally {
201
220
  isRefreshing = false;
202
221
  }
203
- if (refreshed) {
222
+ if (refreshResult === "refreshed") {
204
223
  return apiRequestInternal(endpoint, options, true);
205
- } else {
206
- clearStoredAuth();
207
- config.onLogout?.();
224
+ }
225
+ if (refreshResult === "rejected") {
226
+ onTokenRefreshFailed(true);
227
+ await expireSession();
208
228
  throw new Error("Authentication failed. Please sign in again.");
209
229
  }
230
+ onTokenRefreshFailed(false);
231
+ throw new Error("Authentication service unavailable. Please try again.");
210
232
  } else {
211
233
  const REFRESH_TIMEOUT_MS = 3e4;
212
234
  return new Promise((resolve, reject) => {
@@ -220,11 +242,19 @@ async function apiRequestInternal(endpoint, options, isRetry) {
220
242
  }, REFRESH_TIMEOUT_MS);
221
243
  let unsubscribe = null;
222
244
  try {
223
- unsubscribe = subscribeTokenRefresh(() => {
245
+ unsubscribe = subscribeTokenRefresh((token, terminal) => {
224
246
  if (!settled) {
225
247
  settled = true;
226
248
  clearTimeout(refreshTimeoutId);
227
- apiRequestInternal(endpoint, options, true).then(resolve).catch(reject);
249
+ if (token) {
250
+ apiRequestInternal(endpoint, options, true).then(resolve).catch(reject);
251
+ } else {
252
+ reject(
253
+ new Error(
254
+ terminal ? "Authentication failed. Please sign in again." : "Authentication service unavailable. Please try again."
255
+ )
256
+ );
257
+ }
228
258
  }
229
259
  });
230
260
  } catch (subscribeError) {
@@ -728,6 +758,75 @@ var authApi = {
728
758
  if (params?.type) queryParams.append("type", params.type);
729
759
  const response = await api.get(`/auth/security/events?${queryParams.toString()}`, true, customFetch);
730
760
  return extractData(response);
761
+ },
762
+ // ============================================================================
763
+ // User Roles
764
+ // ============================================================================
765
+ //
766
+ // Managing another user's roles. Requires `users:read` to list and
767
+ // `users:update` to change.
768
+ //
769
+ // Two server rules worth designing around rather than discovering:
770
+ // - A role change does NOT affect that user's live session. Permissions are
771
+ // computed when a token is minted, so the grant takes effect on their next
772
+ // token refresh — not on their next request.
773
+ // - The server refuses a change that would remove a user's last role, or
774
+ // that would grant a role at or above the acting user's own level. Both
775
+ // come back as errors, so surface them rather than assuming success.
776
+ /**
777
+ * List a user's roles.
778
+ *
779
+ * @example
780
+ * ```typescript
781
+ * const { roles, primaryRole } = await authApi.getUserRoles(userId);
782
+ * ```
783
+ */
784
+ async getUserRoles(userId, customFetch) {
785
+ const response = await api.get(
786
+ `/users/${userId}/roles`,
787
+ true,
788
+ customFetch
789
+ );
790
+ return extractData(response);
791
+ },
792
+ /**
793
+ * Grant a role to a user. Idempotent — granting a role they already hold
794
+ * succeeds and returns the unchanged set.
795
+ *
796
+ * @returns The user's full role set after the grant.
797
+ */
798
+ async grantUserRole(userId, role) {
799
+ const response = await api.put(
800
+ `/users/${userId}/roles/${role}`,
801
+ {},
802
+ true
803
+ );
804
+ return extractData(response);
805
+ },
806
+ /**
807
+ * Revoke a role from a user.
808
+ *
809
+ * Rejected by the server if it would leave the user with no roles.
810
+ *
811
+ * @returns The user's full role set after the revoke.
812
+ */
813
+ async revokeUserRole(userId, role) {
814
+ const response = await api.delete(
815
+ `/users/${userId}/roles/${role}`,
816
+ true
817
+ );
818
+ return extractData(response);
819
+ },
820
+ /**
821
+ * Replace a user's entire role set.
822
+ *
823
+ * Roles absent from `roles` are revoked, so read-modify-write against
824
+ * {@link getUserRoles} rather than passing a set built from stale state.
825
+ * Use {@link grantUserRole} / {@link revokeUserRole} for additive changes.
826
+ */
827
+ async setUserRoles(userId, roles) {
828
+ const response = await api.put(`/users/${userId}`, { roles }, true);
829
+ return extractData(response);
731
830
  }
732
831
  };
733
832
 
@@ -1,7 +1,8 @@
1
- import { authApi } from './chunk-2QYFVJEM.js';
2
- import { decodeJWT, isInitialized, getConfig, getStorage, getDefaultStorage } from './chunk-6NTZVDLW.js';
1
+ import { authApi } from './chunk-7DW44JGU.js';
2
+ import { isInitialized, getConfig, hasRole, hasAnyRole, hasAllRoles, decodeJWT, getStorage, getDefaultStorage } from './chunk-S4TOKARB.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.
@@ -1,4 +1,5 @@
1
- import { authApi } from './chunk-2QYFVJEM.js';
1
+ import { authApi } from './chunk-7DW44JGU.js';
2
+ import { getUserRoles, hasAnyRole } from './chunk-S4TOKARB.js';
2
3
 
3
4
  // src/core/guards.ts
4
5
  function isMfaChallengeResponse(response) {
@@ -17,7 +18,7 @@ function getAvailableMethods(response) {
17
18
  // src/core/errors.ts
18
19
  var RoleDeniedError = class _RoleDeniedError extends Error {
19
20
  constructor(user, requiredRoles, redirectTo) {
20
- const userRoles = user.roles?.join(", ") || user.role || "none";
21
+ const userRoles = getUserRoles(user).join(", ") || "none";
21
22
  super(
22
23
  `User "${user.username}" does not have required role(s). Has: [${userRoles}], Required: [${requiredRoles.join(", ")}]`
23
24
  );
@@ -48,9 +49,7 @@ var AuthService = class {
48
49
  async login(credentials, options) {
49
50
  const response = await authApi.login(credentials);
50
51
  if (options?.allowedRoles?.length && !isMfaChallengeResponse(response)) {
51
- const userRoles = response.user.roles || (response.user.role ? [response.user.role] : []);
52
- const hasAllowedRole = options.allowedRoles.some((r) => userRoles.includes(r));
53
- if (!hasAllowedRole) {
52
+ if (!hasAnyRole(response.user, options.allowedRoles)) {
54
53
  throw new RoleDeniedError(
55
54
  response.user,
56
55
  options.allowedRoles,
@@ -60,7 +59,7 @@ var AuthService = class {
60
59
  }
61
60
  if (options?.autoSetAuth !== false && !isMfaChallengeResponse(response)) {
62
61
  try {
63
- const { authStore } = await import('./auth.svelte-WPCYPHEE.js');
62
+ const { authStore } = await import('./auth.svelte-FUE2KVI4.js');
64
63
  authStore.setAuth(
65
64
  response.accessToken,
66
65
  response.refreshToken,
@@ -234,9 +233,7 @@ var AuthService = class {
234
233
  async verifyMFAChallenge(data, options) {
235
234
  const response = await authApi.verifyMFAChallenge(data);
236
235
  if (options?.allowedRoles?.length) {
237
- const userRoles = response.user.roles || (response.user.role ? [response.user.role] : []);
238
- const hasAllowedRole = options.allowedRoles.some((r) => userRoles.includes(r));
239
- if (!hasAllowedRole) {
236
+ if (!hasAnyRole(response.user, options.allowedRoles)) {
240
237
  throw new RoleDeniedError(
241
238
  response.user,
242
239
  options.allowedRoles,
@@ -246,7 +243,7 @@ var AuthService = class {
246
243
  }
247
244
  if (options?.autoSetAuth !== false) {
248
245
  try {
249
- const { authStore } = await import('./auth.svelte-WPCYPHEE.js');
246
+ const { authStore } = await import('./auth.svelte-FUE2KVI4.js');
250
247
  authStore.setAuth(
251
248
  response.accessToken,
252
249
  response.refreshToken,
@@ -392,7 +389,7 @@ function getGreeting(user, includeTime = true) {
392
389
  function formatUserRoles(user, options = {}) {
393
390
  if (!user) return "";
394
391
  const { max, separator = ", ", lowercase = false, capitalize = true } = options;
395
- const roles = user.roles || (user.role ? [user.role] : []);
392
+ const roles = getUserRoles(user);
396
393
  if (roles.length === 0) return "";
397
394
  const formatRole = (role) => {
398
395
  if (lowercase) return role.toLowerCase();
@@ -120,7 +120,7 @@ function isTokenExpired(token) {
120
120
  return true;
121
121
  }
122
122
  const CLOCK_SKEW_MS = 3e4;
123
- return payload.exp * 1e3 < Date.now() - CLOCK_SKEW_MS;
123
+ return payload.exp * 1e3 < Date.now() + CLOCK_SKEW_MS;
124
124
  }
125
125
  function getTokenRemainingTime(token) {
126
126
  const payload = decodeJWT(token);
@@ -151,4 +151,60 @@ function extractClaims(token, claims) {
151
151
  return result;
152
152
  }
153
153
 
154
- export { createMemoryStorage, createSessionStorage, 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,6 +1,6 @@
1
- import { e as LoginCredentials, f as LoginResponse, g as LogoutResponse, R as RegisterData, k as RegisterResponse, U as User, j as ProfileUpdateData, C as ChangePasswordData, n as Session, A as ApiKey, c as CreateApiKeyRequest, d as CreateApiKeyResponse, i as MFAStatus, h as MFASetupResponse, M as MFAChallengeData, D as Device, p as UserPreferences, L as LinkedAccount, m as SecurityEvent, P as Pagination } from '../types-Di6SR1q5.js';
2
- export { a as AuthConfig, b as AuthState, l as ResetPasswordData, S as SSOConfig, o as StorageAdapter, q as createMemoryStorage, r as createSessionStorage, s as getConfig, t as getDefaultStorage, u as getFetch, v as getStorage, w as initAuth, x as isInitialized, y as resetConfig } from '../types-Di6SR1q5.js';
3
- export { A as AuthService, L as LoginOptions, M as MFAVerifyOptions, R as RoleDeniedError, a as authService, f as formatUserRoles, g as getAvatarFallback, b as getDisplayName, c as getGreeting, d as getUserEmail, e as getUserInitials, i as isRoleDeniedError } from '../user-utils-C-JbXqbj.js';
1
+ import { e as LoginCredentials, f as LoginResponse, g as LogoutResponse, R as RegisterData, k as RegisterResponse, U as User, j as ProfileUpdateData, C as ChangePasswordData, n as Session, A as ApiKey, c as CreateApiKeyRequest, d as CreateApiKeyResponse, i as MFAStatus, h as MFASetupResponse, M as MFAChallengeData, D as Device, p as UserPreferences, L as LinkedAccount, m as SecurityEvent, P as Pagination, q as UserRolesResponse } from '../types-DXLdCdNC.js';
2
+ export { a as AuthConfig, b as AuthState, l as ResetPasswordData, S as SSOConfig, o as StorageAdapter, r as createMemoryStorage, s as createSessionStorage, t as getConfig, u as getDefaultStorage, v as getFetch, w as getStorage, x as initAuth, y as isInitialized, z as resetConfig } from '../types-DXLdCdNC.js';
3
+ export { A as AuthService, L as LoginOptions, M as MFAVerifyOptions, R as RoleDeniedError, a as authService, f as formatUserRoles, g as getAvatarFallback, b as getDisplayName, c as getGreeting, d as getUserEmail, e as getUserInitials, i as isRoleDeniedError } from '../user-utils-mQMLo7Il.js';
4
4
 
5
5
  /**
6
6
  * HTTP Client
@@ -284,6 +284,38 @@ declare const authApi: {
284
284
  events: SecurityEvent[];
285
285
  pagination: Pagination;
286
286
  }>;
287
+ /**
288
+ * List a user's roles.
289
+ *
290
+ * @example
291
+ * ```typescript
292
+ * const { roles, primaryRole } = await authApi.getUserRoles(userId);
293
+ * ```
294
+ */
295
+ getUserRoles(userId: string, customFetch?: typeof fetch): Promise<UserRolesResponse>;
296
+ /**
297
+ * Grant a role to a user. Idempotent — granting a role they already hold
298
+ * succeeds and returns the unchanged set.
299
+ *
300
+ * @returns The user's full role set after the grant.
301
+ */
302
+ grantUserRole(userId: string, role: string): Promise<UserRolesResponse>;
303
+ /**
304
+ * Revoke a role from a user.
305
+ *
306
+ * Rejected by the server if it would leave the user with no roles.
307
+ *
308
+ * @returns The user's full role set after the revoke.
309
+ */
310
+ revokeUserRole(userId: string, role: string): Promise<UserRolesResponse>;
311
+ /**
312
+ * Replace a user's entire role set.
313
+ *
314
+ * Roles absent from `roles` are revoked, so read-modify-write against
315
+ * {@link getUserRoles} rather than passing a set built from stale state.
316
+ * Use {@link grantUserRole} / {@link revokeUserRole} for additive changes.
317
+ */
318
+ setUserRoles(userId: string, roles: string[]): Promise<User>;
287
319
  };
288
320
 
289
321
  /**
@@ -413,4 +445,111 @@ declare function getTokenExpiration(token: string): Date | null;
413
445
  */
414
446
  declare function extractClaims<T extends string>(token: string, claims: T[]): Pick<JWTPayload, T> | null;
415
447
 
416
- export { ApiKey, type ApiRequestOptions, type ApiResponse, ChangePasswordData, CreateApiKeyRequest, CreateApiKeyResponse, Device, type JWTPayload, LinkedAccount, LoginCredentials, LoginResponse, LogoutResponse, MFAChallengeData, MFASetupResponse, MFAStatus, Pagination, ProfileUpdateData, RegisterData, RegisterResponse, SecurityEvent, Session, User, UserPreferences, api, apiRequest, authApi, clearStoredAuth, decodeJWT, extractClaims, extractData, getAccessToken, getAvailableMethods, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, updateStoredTokens };
448
+ /**
449
+ * Role Utilities
450
+ *
451
+ * A user holds a set of roles. Their effective permissions are the UNION of
452
+ * every role's permissions and their effective level is the MAX tier across
453
+ * them — the server computes both and bakes the result into the JWT.
454
+ *
455
+ * This module mirrors the role *taxonomy* from the API
456
+ * (`chapi/src/config/roles.ts`, `chapi/src/lib/users/roles.ts`). It deliberately
457
+ * does NOT mirror the permission definitions: `permissions` arrives pre-computed
458
+ * on the token, so duplicating the role→permission mapping here would only give
459
+ * it a second place to drift.
460
+ *
461
+ * SECURITY: everything here is for UI decisions only. Authorization is enforced
462
+ * server-side; a client-side role check is a hint, not a gate.
463
+ */
464
+
465
+ /**
466
+ * Every role name, ordered from most to least privileged.
467
+ *
468
+ * Order is load-bearing: {@link computePrimaryRole} walks this list and takes
469
+ * the first match, which is what makes the result deterministic when several
470
+ * roles share a tier (e.g. `agent` + `builder`).
471
+ *
472
+ * Mirrors `ROLE_NAMES` in the API. `builder`/`customer_service`/`demo` are
473
+ * staff-tier clones of `agent`; `vendor`/`customer`/`subcontractor` are clones
474
+ * of `user`, row-scoped to their own homes.
475
+ */
476
+ declare const ROLE_NAMES: readonly ["admin", "manager", "agent", "builder", "customer_service", "demo", "user", "vendor", "customer", "subcontractor", "guest"];
477
+ /**
478
+ * A known role name.
479
+ *
480
+ * Note that role-carrying values stay `string` at API boundaries — the server
481
+ * can add a role before this package ships a matching release, and an unknown
482
+ * role must degrade (contribute no permissions, level 0) rather than break
483
+ * decoding. This type is for authoring ergonomics, not runtime validation.
484
+ */
485
+ type RoleName = (typeof ROLE_NAMES)[number];
486
+ /**
487
+ * Privilege level per role. Higher wins. Mirrors `ROLE_HIERARCHY` in the API.
488
+ *
489
+ * Used only to derive a display-facing primary role and effective level —
490
+ * never to imply one role from another. See {@link computePrimaryRole}.
491
+ */
492
+ declare const ROLE_HIERARCHY: Record<string, number>;
493
+ /**
494
+ * Read a user's role set.
495
+ *
496
+ * The single place that normalizes roles off a user object, so callers never
497
+ * have to care whether the field is absent (e.g. a hand-built object, or a
498
+ * response shape that predates multi-role support).
499
+ *
500
+ * @example
501
+ * ```typescript
502
+ * const roles = getUserRoles(user); // ['manager', 'agent']
503
+ * ```
504
+ */
505
+ declare function getUserRoles(user: Pick<User, 'roles'> | null | undefined): string[];
506
+ /**
507
+ * The highest-tier role a user holds, for display.
508
+ *
509
+ * Resolved by {@link ROLE_NAMES} order rather than by comparing levels, so ties
510
+ * within a tier resolve deterministically and identically to the server.
511
+ * Returns `guest` when the set is empty or holds only unrecognized roles.
512
+ *
513
+ * This is a label, not a capability — a user's actual access is the union of
514
+ * all their roles, which is strictly broader than their primary role alone.
515
+ * Never gate on this; use {@link hasRole} or the store's role predicates.
516
+ *
517
+ * @example
518
+ * ```typescript
519
+ * computePrimaryRole(['user', 'admin']); // 'admin'
520
+ * computePrimaryRole(['agent', 'builder']); // 'agent' — ROLE_NAMES order breaks the tie
521
+ * computePrimaryRole([]); // 'guest'
522
+ * ```
523
+ */
524
+ declare function computePrimaryRole(roles: readonly string[]): RoleName;
525
+ /**
526
+ * Effective privilege level — the MAX across all roles held.
527
+ * Unrecognized roles contribute 0.
528
+ *
529
+ * @example
530
+ * ```typescript
531
+ * getEffectiveLevel(['user', 'manager']); // 3
532
+ * getEffectiveLevel([]); // 0
533
+ * ```
534
+ */
535
+ declare function getEffectiveLevel(roles: readonly string[]): number;
536
+ /**
537
+ * Whether a user holds a specific role.
538
+ *
539
+ * Exact membership — holding `admin` does NOT imply `agent`. This matches the
540
+ * API's `requireRole`, so the UI can't offer something the server will then
541
+ * reject with a 403.
542
+ */
543
+ declare function hasRole(user: Pick<User, 'roles'> | null | undefined, role: string): boolean;
544
+ /**
545
+ * Whether a user holds at least one of the given roles.
546
+ * An empty `roles` list matches nothing.
547
+ */
548
+ declare function hasAnyRole(user: Pick<User, 'roles'> | null | undefined, roles: readonly string[]): boolean;
549
+ /**
550
+ * Whether a user holds every one of the given roles.
551
+ * An empty `roles` list matches vacuously (nothing was required).
552
+ */
553
+ declare function hasAllRoles(user: Pick<User, 'roles'> | null | undefined, roles: readonly string[]): boolean;
554
+
555
+ export { ApiKey, type ApiRequestOptions, type ApiResponse, ChangePasswordData, CreateApiKeyRequest, CreateApiKeyResponse, Device, type JWTPayload, LinkedAccount, LoginCredentials, LoginResponse, LogoutResponse, MFAChallengeData, MFASetupResponse, MFAStatus, Pagination, ProfileUpdateData, ROLE_HIERARCHY, ROLE_NAMES, RegisterData, RegisterResponse, type RoleName, SecurityEvent, Session, User, UserPreferences, UserRolesResponse, api, apiRequest, authApi, clearStoredAuth, computePrimaryRole, decodeJWT, extractClaims, extractData, getAccessToken, getAvailableMethods, getEffectiveLevel, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, updateStoredTokens };
@@ -1,3 +1,3 @@
1
- export { AuthService, RoleDeniedError, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse, isRoleDeniedError } from '../chunk-MPPV5S7S.js';
2
- export { api, apiRequest, authApi, clearStoredAuth, extractData, getAccessToken, getRefreshToken, getSessionToken, updateStoredTokens } from '../chunk-2QYFVJEM.js';
3
- export { createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, initAuth, isInitialized, isTokenExpired, resetConfig } from '../chunk-6NTZVDLW.js';
1
+ export { AuthService, RoleDeniedError, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse, isRoleDeniedError } from '../chunk-LIRO3PU2.js';
2
+ export { api, apiRequest, authApi, clearStoredAuth, extractData, getAccessToken, getRefreshToken, getSessionToken, updateStoredTokens } from '../chunk-7DW44JGU.js';
3
+ export { ROLE_HIERARCHY, ROLE_NAMES, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isInitialized, isTokenExpired, resetConfig } from '../chunk-S4TOKARB.js';
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { A as ApiKey, a as AuthConfig, b as AuthState, C as ChangePasswordData, c as CreateApiKeyRequest, d as CreateApiKeyResponse, D as Device, L as LinkedAccount, e as LoginCredentials, f as LoginResponse, g as LogoutResponse, M as MFAChallengeData, h as MFASetupResponse, i as MFAStatus, P as Pagination, j as ProfileUpdateData, R as RegisterData, k as RegisterResponse, l as ResetPasswordData, S as SSOConfig, m as SecurityEvent, n as Session, o as StorageAdapter, U as User, p as UserPreferences, q as createMemoryStorage, r as createSessionStorage, s as getConfig, t as getDefaultStorage, u as getFetch, v as getStorage, w as initAuth, x as isInitialized, y as resetConfig } from './types-Di6SR1q5.js';
2
- export { ApiRequestOptions, ApiResponse, JWTPayload, api, apiRequest, authApi, clearStoredAuth, decodeJWT, extractClaims, extractData, getAccessToken, getAvailableMethods, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, updateStoredTokens } from './core/index.js';
3
- export { A as AuthService, L as LoginOptions, M as MFAVerifyOptions, R as RoleDeniedError, a as authService, f as formatUserRoles, g as getAvatarFallback, b as getDisplayName, c as getGreeting, d as getUserEmail, e as getUserInitials, i as isRoleDeniedError } from './user-utils-C-JbXqbj.js';
1
+ export { A as ApiKey, a as AuthConfig, b as AuthState, C as ChangePasswordData, c as CreateApiKeyRequest, d as CreateApiKeyResponse, D as Device, L as LinkedAccount, e as LoginCredentials, f as LoginResponse, g as LogoutResponse, M as MFAChallengeData, h as MFASetupResponse, i as MFAStatus, P as Pagination, j as ProfileUpdateData, R as RegisterData, k as RegisterResponse, l as ResetPasswordData, S as SSOConfig, m as SecurityEvent, n as Session, o as StorageAdapter, U as User, p as UserPreferences, q as UserRolesResponse, r as createMemoryStorage, s as createSessionStorage, t as getConfig, u as getDefaultStorage, v as getFetch, w as getStorage, x as initAuth, y as isInitialized, z as resetConfig } from './types-DXLdCdNC.js';
2
+ export { ApiRequestOptions, ApiResponse, JWTPayload, ROLE_HIERARCHY, ROLE_NAMES, RoleName, api, apiRequest, authApi, clearStoredAuth, computePrimaryRole, decodeJWT, extractClaims, extractData, getAccessToken, getAvailableMethods, getEffectiveLevel, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, updateStoredTokens } from './core/index.js';
3
+ export { A as AuthService, L as LoginOptions, M as MFAVerifyOptions, R as RoleDeniedError, a as authService, f as formatUserRoles, g as getAvatarFallback, b as getDisplayName, c as getGreeting, d as getUserEmail, e as getUserInitials, i as isRoleDeniedError } from './user-utils-mQMLo7Il.js';
4
4
  export { AuthClient, AuthGuardOptions, AuthGuardResult, AuthHookOptions, CreateAuthClientOptions, NavFilterOptions, RoleRestrictedItem, authActions, authStore, canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, currentUser, filterByAccess, filterNavSections, isAuthenticated, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from './svelte/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from './chunk-UG4GSPGL.js';
2
- export { AuthService, RoleDeniedError, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse, isRoleDeniedError } from './chunk-MPPV5S7S.js';
3
- export { authActions, authStore, currentUser, isAuthenticated } from './chunk-CPFM7JEI.js';
4
- export { api, apiRequest, authApi, clearStoredAuth, extractData, getAccessToken, getRefreshToken, getSessionToken, updateStoredTokens } from './chunk-2QYFVJEM.js';
5
- export { createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, initAuth, isInitialized, isTokenExpired, resetConfig } from './chunk-6NTZVDLW.js';
1
+ export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from './chunk-6VABYYP2.js';
2
+ export { AuthService, RoleDeniedError, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse, isRoleDeniedError } from './chunk-LIRO3PU2.js';
3
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-GWOTYGQI.js';
4
+ export { api, apiRequest, authApi, clearStoredAuth, extractData, getAccessToken, getRefreshToken, getSessionToken, updateStoredTokens } from './chunk-7DW44JGU.js';
5
+ export { ROLE_HIERARCHY, ROLE_NAMES, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isInitialized, isTokenExpired, resetConfig } from './chunk-S4TOKARB.js';
@@ -1,7 +1,7 @@
1
- import { b as AuthState, U as User, a as AuthConfig } from '../types-Di6SR1q5.js';
2
- export { w as initAuth, x as isInitialized } from '../types-Di6SR1q5.js';
3
- import { a as authService } from '../user-utils-C-JbXqbj.js';
4
- export { R as RoleDeniedError, f as formatUserRoles, g as getAvatarFallback, b as getDisplayName, c as getGreeting, d as getUserEmail, e as getUserInitials, i as isRoleDeniedError } from '../user-utils-C-JbXqbj.js';
1
+ import { b as AuthState, U as User, a as AuthConfig } from '../types-DXLdCdNC.js';
2
+ export { x as initAuth, y as isInitialized } from '../types-DXLdCdNC.js';
3
+ import { a as authService } from '../user-utils-mQMLo7Il.js';
4
+ export { R as RoleDeniedError, f as formatUserRoles, g as getAvatarFallback, b as getDisplayName, c as getGreeting, d as getUserEmail, e as getUserInitials, i as isRoleDeniedError } from '../user-utils-mQMLo7Il.js';
5
5
 
6
6
  /**
7
7
  * Auth Store
@@ -69,15 +69,17 @@ declare class AuthStore {
69
69
  */
70
70
  hasPermission(permission: string): boolean;
71
71
  /**
72
- * Check if user has a specific role.
72
+ * Check if the user holds a specific role.
73
+ *
74
+ * Exact membership — holding `admin` does not imply `agent`.
73
75
  */
74
76
  hasRole(role: string): boolean;
75
77
  /**
76
- * Check if user has any of the specified roles.
78
+ * Check if the user holds any of the specified roles.
77
79
  */
78
80
  hasAnyRole(roles: string[]): boolean;
79
81
  /**
80
- * Check if user has all of the specified roles.
82
+ * Check if the user holds all of the specified roles.
81
83
  */
82
84
  hasAllRoles(roles: string[]): boolean;
83
85
  /**
@@ -379,6 +381,13 @@ declare function createAuthClient(options: CreateAuthClientOptions): AuthClient;
379
381
  interface RoleRestrictedItem {
380
382
  /** Optional item identifier */
381
383
  id?: string;
384
+ /**
385
+ * Where this item navigates to, if anywhere.
386
+ *
387
+ * Only consulted when pruning empty parents: an item with an `href` survives
388
+ * losing all its children, since it remains a destination in its own right.
389
+ */
390
+ href?: string;
382
391
  /**
383
392
  * Roles that can see this item.
384
393
  * By default, user needs ANY of these roles (use requireAllRoles for ALL).
@@ -411,6 +420,27 @@ interface NavFilterOptions {
411
420
  * If false (default), items without restrictions are visible to everyone.
412
421
  */
413
422
  hideUnrestrictedForGuests?: boolean;
423
+ /**
424
+ * Property holding nested child items (e.g. 'children' for submenus).
425
+ *
426
+ * Set this for tree-shaped navigation. Without it, filtering is flat: a
427
+ * restricted child under an unrestricted parent stays visible, because
428
+ * nothing ever looks inside the parent.
429
+ *
430
+ * @example
431
+ * ```typescript
432
+ * filterByAccess(navItems, user, { childrenKey: 'children' });
433
+ * ```
434
+ */
435
+ childrenKey?: string;
436
+ /**
437
+ * If true, a parent whose children are all filtered away is dropped too.
438
+ *
439
+ * Only applies to items that had children to begin with, and only when they
440
+ * are pure containers — an item with its own `href` stays, since it is still
441
+ * navigable on its own. Requires `childrenKey`. Default: true.
442
+ */
443
+ pruneEmptyParents?: boolean;
414
444
  }
415
445
  /**
416
446
  * Filter navigation items based on user roles and permissions.
@@ -431,6 +461,12 @@ interface NavFilterOptions {
431
461
  * const filtered = filterByAccess(navItems, user);
432
462
  * // Returns items the user can see based on their roles/permissions
433
463
  * ```
464
+ *
465
+ * @example
466
+ * ```typescript
467
+ * // Tree-shaped navigation — without childrenKey, nested items are NOT filtered
468
+ * const filtered = filterByAccess(navItems, user, { childrenKey: 'children' });
469
+ * ```
434
470
  */
435
471
  declare function filterByAccess<T extends RoleRestrictedItem>(items: T[], user: User | null, options?: NavFilterOptions): T[];
436
472
  /**
@@ -1,5 +1,5 @@
1
- export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from '../chunk-UG4GSPGL.js';
2
- export { RoleDeniedError, formatUserRoles, getAvatarFallback, getDisplayName, getGreeting, getUserEmail, getUserInitials, isRoleDeniedError } from '../chunk-MPPV5S7S.js';
3
- export { authActions, authStore, currentUser, isAuthenticated } from '../chunk-CPFM7JEI.js';
4
- import '../chunk-2QYFVJEM.js';
5
- export { initAuth, isInitialized } from '../chunk-6NTZVDLW.js';
1
+ export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from '../chunk-6VABYYP2.js';
2
+ export { RoleDeniedError, formatUserRoles, getAvatarFallback, getDisplayName, getGreeting, getUserEmail, getUserInitials, isRoleDeniedError } from '../chunk-LIRO3PU2.js';
3
+ export { authActions, authStore, currentUser, isAuthenticated } from '../chunk-GWOTYGQI.js';
4
+ import '../chunk-7DW44JGU.js';
5
+ export { initAuth, isInitialized } from '../chunk-S4TOKARB.js';
@@ -1,4 +1,4 @@
1
- import { U as User, D as Device, f as LoginResponse, m as SecurityEvent, n as Session, A as ApiKey, L as LinkedAccount, g as LogoutResponse, h as MFASetupResponse, i as MFAStatus, k as RegisterResponse, p as UserPreferences, o as StorageAdapter, b as AuthState, a as AuthConfig } from '../types-Di6SR1q5.js';
1
+ import { U as User, D as Device, f as LoginResponse, m as SecurityEvent, n as Session, A as ApiKey, L as LinkedAccount, g as LogoutResponse, h as MFASetupResponse, i as MFAStatus, k as RegisterResponse, p as UserPreferences, o as StorageAdapter, b as AuthState, a as AuthConfig } from '../types-DXLdCdNC.js';
2
2
 
3
3
  /**
4
4
  * User Fixtures
@@ -588,15 +588,18 @@ declare class MockAuthStore {
588
588
  */
589
589
  hasPermission(permission: string): boolean;
590
590
  /**
591
- * Check if user has a specific role.
591
+ * Check if the user holds a specific role.
592
+ *
593
+ * Delegates to the same helpers as the real store — a mock that decides
594
+ * access differently from production is worse than no mock.
592
595
  */
593
596
  hasRole(role: string): boolean;
594
597
  /**
595
- * Check if user has any of the specified roles.
598
+ * Check if the user holds any of the specified roles.
596
599
  */
597
600
  hasAnyRole(roles: string[]): boolean;
598
601
  /**
599
- * Check if user has all of the specified roles.
602
+ * Check if the user holds all of the specified roles.
600
603
  */
601
604
  hasAllRoles(roles: string[]): boolean;
602
605
  /**
@@ -1,4 +1,4 @@
1
- import { initAuth, resetConfig, isTokenExpired, decodeJWT } from '../chunk-6NTZVDLW.js';
1
+ import { hasRole, hasAnyRole, hasAllRoles, initAuth, resetConfig, getUserRoles, isTokenExpired, decodeJWT } from '../chunk-S4TOKARB.js';
2
2
 
3
3
  // src/testing/fixtures/users.ts
4
4
  var mockUser = {
@@ -8,7 +8,6 @@ var mockUser = {
8
8
  firstName: "Test",
9
9
  lastName: "User",
10
10
  phone: "+1234567890",
11
- role: "user",
12
11
  roles: ["user"],
13
12
  permissions: ["read:profile", "write:profile"],
14
13
  isActive: true,
@@ -24,7 +23,6 @@ var mockAdminUser = {
24
23
  firstName: "Admin",
25
24
  lastName: "User",
26
25
  phone: "+1234567891",
27
- role: "admin",
28
26
  roles: ["admin", "user"],
29
27
  permissions: [
30
28
  "read:profile",
@@ -48,7 +46,6 @@ var mockSSOUser = {
48
46
  email: "sso@example.com",
49
47
  firstName: "SSO",
50
48
  lastName: "User",
51
- role: "user",
52
49
  roles: ["user"],
53
50
  permissions: ["read:profile", "write:profile"],
54
51
  isActive: true,
@@ -64,7 +61,6 @@ var mockMFAUser = {
64
61
  email: "mfa@example.com",
65
62
  firstName: "MFA",
66
63
  lastName: "User",
67
- role: "user",
68
64
  roles: ["user"],
69
65
  permissions: ["read:profile", "write:profile"],
70
66
  isActive: true,
@@ -79,7 +75,6 @@ var mockUnverifiedUser = {
79
75
  email: "unverified@example.com",
80
76
  firstName: "Unverified",
81
77
  lastName: "User",
82
- role: "user",
83
78
  roles: ["user"],
84
79
  permissions: [],
85
80
  isActive: true,
@@ -93,7 +88,6 @@ var mockInactiveUser = {
93
88
  email: "inactive@example.com",
94
89
  firstName: "Inactive",
95
90
  lastName: "User",
96
- role: "user",
97
91
  roles: ["user"],
98
92
  permissions: [],
99
93
  isActive: false,
@@ -110,7 +104,6 @@ function createMockUser(overrides = {}) {
110
104
  }
111
105
  function createMockUserWithRoles(roles, permissions, overrides = {}) {
112
106
  return createMockUser({
113
- role: roles[0] ?? "user",
114
107
  roles,
115
108
  permissions,
116
109
  ...overrides
@@ -1279,22 +1272,25 @@ var MockAuthStore = class {
1279
1272
  return this.state.user?.permissions?.includes(permission) ?? false;
1280
1273
  }
1281
1274
  /**
1282
- * Check if user has a specific role.
1275
+ * Check if the user holds a specific role.
1276
+ *
1277
+ * Delegates to the same helpers as the real store — a mock that decides
1278
+ * access differently from production is worse than no mock.
1283
1279
  */
1284
1280
  hasRole(role) {
1285
- return this.state.user?.roles?.includes(role) ?? false;
1281
+ return hasRole(this.state.user, role);
1286
1282
  }
1287
1283
  /**
1288
- * Check if user has any of the specified roles.
1284
+ * Check if the user holds any of the specified roles.
1289
1285
  */
1290
1286
  hasAnyRole(roles) {
1291
- return roles.some((role) => this.state.user?.roles?.includes(role)) ?? false;
1287
+ return hasAnyRole(this.state.user, roles);
1292
1288
  }
1293
1289
  /**
1294
- * Check if user has all of the specified roles.
1290
+ * Check if the user holds all of the specified roles.
1295
1291
  */
1296
1292
  hasAllRoles(roles) {
1297
- return roles.every((role) => this.state.user?.roles?.includes(role)) ?? false;
1293
+ return hasAllRoles(this.state.user, roles);
1298
1294
  }
1299
1295
  /**
1300
1296
  * Check if user has any of the specified permissions.
@@ -1833,13 +1829,15 @@ function assertLacksPermissions(user, permissions, message) {
1833
1829
  }
1834
1830
  }
1835
1831
  function assertHasRoles(user, roles, message) {
1836
- const missing = roles.filter((r) => !user.roles?.includes(r));
1832
+ const held = getUserRoles(user);
1833
+ const missing = roles.filter((r) => !held.includes(r));
1837
1834
  if (missing.length > 0) {
1838
1835
  throw new Error(message ?? `User missing roles: ${missing.join(", ")}`);
1839
1836
  }
1840
1837
  }
1841
1838
  function assertLacksRoles(user, roles, message) {
1842
- const present = roles.filter((r) => user.roles?.includes(r));
1839
+ const held = getUserRoles(user);
1840
+ const present = roles.filter((r) => held.includes(r));
1843
1841
  if (present.length > 0) {
1844
1842
  throw new Error(message ?? `User should not have roles: ${present.join(", ")}`);
1845
1843
  }
@@ -149,8 +149,13 @@ interface User {
149
149
  firstName?: string;
150
150
  lastName?: string;
151
151
  phone?: string;
152
- role: string;
153
- roles?: string[];
152
+ /**
153
+ * Every role the user holds. `permissions` is the union across all of them,
154
+ * so this is broader than any single role — see `computePrimaryRole` in
155
+ * `./roles.js` if you need one role to display.
156
+ */
157
+ roles: string[];
158
+ /** Union of the permissions granted by every role in `roles`. */
154
159
  permissions: string[];
155
160
  isActive: boolean;
156
161
  emailVerified: boolean;
@@ -159,6 +164,21 @@ interface User {
159
164
  createdAt: string;
160
165
  lastLoginAt?: string;
161
166
  }
167
+ /**
168
+ * The result of reading or changing a user's roles.
169
+ */
170
+ interface UserRolesResponse {
171
+ userId: string;
172
+ /** The user's full role set after the operation. */
173
+ roles: string[];
174
+ /**
175
+ * The highest-tier role in `roles`, as resolved by the server.
176
+ *
177
+ * A display label, not a capability — the user's actual access is the union
178
+ * of every role in `roles`, which is broader than this one alone.
179
+ */
180
+ primaryRole: string;
181
+ }
162
182
  interface AuthState {
163
183
  accessToken: string | null;
164
184
  refreshToken: string | null;
@@ -227,6 +247,10 @@ interface ApiKey {
227
247
  description?: string;
228
248
  keyPreview: string;
229
249
  permissions: string[];
250
+ /**
251
+ * The single role this key acts as. Unlike a user — who holds a set of roles
252
+ * whose permissions are unioned — an API key is scoped to at most one role.
253
+ */
230
254
  role: string | null;
231
255
  isActive: boolean;
232
256
  expiresAt: string | null;
@@ -347,4 +371,4 @@ interface ResetPasswordData {
347
371
  newPassword: string;
348
372
  }
349
373
 
350
- export { type ApiKey as A, type ChangePasswordData as C, type Device as D, type LinkedAccount as L, type MFAChallengeData as M, type Pagination as P, type RegisterData as R, type SSOConfig as S, type User as U, type AuthConfig as a, type AuthState as b, type CreateApiKeyRequest as c, type CreateApiKeyResponse as d, type LoginCredentials as e, type LoginResponse as f, type LogoutResponse as g, type MFASetupResponse as h, type MFAStatus as i, type ProfileUpdateData as j, type RegisterResponse as k, type ResetPasswordData as l, type SecurityEvent as m, type Session as n, type StorageAdapter as o, type UserPreferences as p, createMemoryStorage as q, createSessionStorage as r, getConfig as s, getDefaultStorage as t, getFetch as u, getStorage as v, initAuth as w, isInitialized as x, resetConfig as y };
374
+ export { type ApiKey as A, type ChangePasswordData as C, type Device as D, type LinkedAccount as L, type MFAChallengeData as M, type Pagination as P, type RegisterData as R, type SSOConfig as S, type User as U, type AuthConfig as a, type AuthState as b, type CreateApiKeyRequest as c, type CreateApiKeyResponse as d, type LoginCredentials as e, type LoginResponse as f, type LogoutResponse as g, type MFASetupResponse as h, type MFAStatus as i, type ProfileUpdateData as j, type RegisterResponse as k, type ResetPasswordData as l, type SecurityEvent as m, type Session as n, type StorageAdapter as o, type UserPreferences as p, type UserRolesResponse as q, createMemoryStorage as r, createSessionStorage as s, getConfig as t, getDefaultStorage as u, getFetch as v, getStorage as w, initAuth as x, isInitialized as y, resetConfig as z };
@@ -1,4 +1,4 @@
1
- import { e as LoginCredentials, f as LoginResponse, g as LogoutResponse, R as RegisterData, k as RegisterResponse, U as User, j as ProfileUpdateData, n as Session, A as ApiKey, c as CreateApiKeyRequest, d as CreateApiKeyResponse, i as MFAStatus, h as MFASetupResponse, M as MFAChallengeData, D as Device, p as UserPreferences, L as LinkedAccount, m as SecurityEvent, P as Pagination } from './types-Di6SR1q5.js';
1
+ import { e as LoginCredentials, f as LoginResponse, g as LogoutResponse, R as RegisterData, k as RegisterResponse, U as User, j as ProfileUpdateData, n as Session, A as ApiKey, c as CreateApiKeyRequest, d as CreateApiKeyResponse, i as MFAStatus, h as MFASetupResponse, M as MFAChallengeData, D as Device, p as UserPreferences, L as LinkedAccount, m as SecurityEvent, P as Pagination } from './types-DXLdCdNC.js';
2
2
 
3
3
  /**
4
4
  * Auth Service
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/auth",
3
- "version": "0.1.56",
3
+ "version": "1.0.2",
4
4
  "description": "Authentication services and Svelte bindings for Classic Theme apps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,3 +0,0 @@
1
- export { authActions, authStore, currentUser, isAuthenticated } from './chunk-CPFM7JEI.js';
2
- import './chunk-2QYFVJEM.js';
3
- import './chunk-6NTZVDLW.js';