@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 +87 -1
- package/dist/auth.svelte-J62VV4TA.js +3 -0
- package/dist/{chunk-GRQL5FSH.js → chunk-2N2MDCWL.js} +130 -20
- package/dist/{chunk-RKSETJ4V.js → chunk-ILRNO4WH.js} +30 -7
- package/dist/{chunk-EM7PUNZB.js → chunk-O47YKG2F.js} +87 -2
- package/dist/{chunk-BK34CVH5.js → chunk-QBTMNWJH.js} +46 -42
- package/dist/{chunk-TUHQ4FHC.js → chunk-VVREZ25E.js} +8 -11
- package/dist/core/index.d.ts +147 -7
- package/dist/core/index.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +5 -5
- package/dist/svelte/index.d.ts +43 -7
- package/dist/svelte/index.js +5 -5
- package/dist/testing/index.d.ts +7 -4
- package/dist/testing/index.js +14 -16
- package/dist/{types-BRxyphcX.d.ts → types-DXLdCdNC.d.ts} +54 -3
- package/dist/{user-utils-BSbNN3Hb.d.ts → user-utils-mQMLo7Il.d.ts} +1 -1
- package/package.json +1 -1
- package/dist/auth.svelte-BBLX2MKB.js +0 -3
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.
|
|
731
|
+
expect(mockUser.roles).toEqual(['user']);
|
|
646
732
|
expect(mockAdminUser.permissions).toContain('manage:system');
|
|
647
733
|
|
|
648
734
|
// Create custom users
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getConfig, getStorage, getFetch, isValidRedirectUrl } from './chunk-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
197
|
+
let refreshed = false;
|
|
198
|
+
try {
|
|
199
|
+
refreshed = await refreshAccessToken();
|
|
200
|
+
} finally {
|
|
201
|
+
isRefreshing = false;
|
|
202
|
+
}
|
|
186
203
|
if (refreshed) {
|
|
187
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
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
|
|
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-
|
|
2
|
-
import { authStore, currentUser, isAuthenticated, authActions } from './chunk-
|
|
3
|
-
import { isInitialized, initAuth } from './chunk-
|
|
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 =
|
|
95
|
+
const userRoles = getUserRoles(user);
|
|
94
96
|
const userPerms = user?.permissions || [];
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
2
|
-
import {
|
|
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
|
|
37
|
+
accessToken,
|
|
29
38
|
refreshToken: parsed.refreshToken ?? null,
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
245
|
+
return hasRole(this.state.user, role);
|
|
242
246
|
}
|
|
243
247
|
/**
|
|
244
|
-
* Check if user
|
|
248
|
+
* Check if the user holds any of the specified roles.
|
|
245
249
|
*/
|
|
246
250
|
hasAnyRole(roles) {
|
|
247
|
-
return
|
|
251
|
+
return hasAnyRole(this.state.user, roles);
|
|
248
252
|
}
|
|
249
253
|
/**
|
|
250
|
-
* Check if user
|
|
254
|
+
* Check if the user holds all of the specified roles.
|
|
251
255
|
*/
|
|
252
256
|
hasAllRoles(roles) {
|
|
253
|
-
return
|
|
257
|
+
return hasAllRoles(this.state.user, roles);
|
|
254
258
|
}
|
|
255
259
|
/**
|
|
256
260
|
* Check if user has any of the specified permissions.
|