@onehat/ui 0.4.108 → 0.4.111

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.
@@ -0,0 +1,584 @@
1
+ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
2
+ import oneHatData from '@onehat/data';
3
+ import {
4
+ getUserData,
5
+ getUserToken,
6
+ setRepositoryAuthHeaders,
7
+ } from '../../Functions/authFunctions.js';
8
+ import {
9
+ AUTH_STATUS_UNKNOWN,
10
+ AUTH_STATUS_AUTHENTICATED,
11
+ AUTH_STATUS_UNAUTHENTICATED,
12
+ } from '../../Constants/Auth.js';
13
+ import getSecure from '../../Functions/getSecure';
14
+ import setSecure from '../../Functions/setSecure';
15
+ import deleteSecure from '../../Functions/deleteSecure';
16
+ import { setAlertMessage } from './SystemSlice.js';
17
+ import { CROSS_TAB_EVENT_NAME } from '@onehat/data/src/Integration/Browser/Repository/crossTabConstants.js';
18
+ import _ from 'lodash';
19
+
20
+ const EXPIRED_MESSAGE = 'Your session has expired. Please log in again.';
21
+ const USER_CREDS = 'USER_CREDS-';
22
+ let refreshAuthTokenPromise = null;
23
+ let onSetUserCallback = null;
24
+ let onCrossTabRehydrateCallback = null;
25
+
26
+
27
+ // Thunks
28
+
29
+ export const setUserThunk = createAsyncThunk(
30
+ 'auth/setUser',
31
+ async (user, { dispatch }) => {
32
+ const userData = getUserData(user);
33
+ dispatch(setUser(userData));
34
+
35
+ const token = getUserToken(userData);
36
+ setRepositoryAuthHeaders(token);
37
+ dispatch(setAuthStatus(token ? AUTH_STATUS_AUTHENTICATED : AUTH_STATUS_UNAUTHENTICATED));
38
+
39
+ if (user) {
40
+ await setSecure('user', user);
41
+ } else {
42
+ await deleteSecure('user');
43
+ }
44
+
45
+ if (onSetUserCallback) {
46
+ await onSetUserCallback(userData);
47
+ }
48
+ }
49
+ );
50
+
51
+ export const setGroupsThunk = createAsyncThunk(
52
+ 'auth/setGroups',
53
+ async (groups, { dispatch }) => {
54
+ dispatch(setGroups(groups));
55
+
56
+ if (!_.isEmpty(groups)) {
57
+ await setSecure('groups', groups);
58
+ } else {
59
+ await deleteSecure('groups');
60
+ }
61
+ }
62
+ );
63
+
64
+ export const setPermissionsThunk = createAsyncThunk(
65
+ 'auth/setPermissions',
66
+ async (permissions, { dispatch }) => {
67
+ if (_.isPlainObject(permissions)) {
68
+ permissions = _.map(permissions, (value, key) => {
69
+ if (!parseInt(value)) {
70
+ return null;
71
+ }
72
+ return key;
73
+ });
74
+ }
75
+
76
+ dispatch(setPermissions(permissions));
77
+
78
+ if (!_.isEmpty(permissions)) {
79
+ await setSecure('permissions', permissions);
80
+ } else {
81
+ await deleteSecure('permissions');
82
+ }
83
+ }
84
+ );
85
+
86
+ /**
87
+ * crossTabRehydrateThunk
88
+ *
89
+ * Handles Secure repository cross-tab storage events.
90
+ * Rehydrates auth-related Redux state from secure storage without writing back,
91
+ * so we avoid rebroadcast loops.
92
+ *
93
+ * Net effect: login/logout/token refresh in one tab is mirrored in other tabs.
94
+ */
95
+ const crossTabRehydrateThunk = createAsyncThunk(
96
+ 'auth/crossTabRehydrate',
97
+ async ({ key, operation }, { dispatch, getState }) => {
98
+ let shouldRecalculateTabVisibility = false,
99
+ handled = false;
100
+
101
+ // Full secure wipe from another tab means immediate local sign-out.
102
+ if (operation === 'clearAll') {
103
+ setRepositoryAuthHeaders(null);
104
+ dispatch(setUser(null)); // don't use setUserThunk because that would write to secure storage, which would trigger another broadcast, etc.
105
+ dispatch(setGroups(null));
106
+ dispatch(setPermissions(null));
107
+ dispatch(setAuthStatus(AUTH_STATUS_UNAUTHENTICATED));
108
+ shouldRecalculateTabVisibility = true;
109
+ handled = true;
110
+ }
111
+
112
+ // For delete events, do not call Secure.load(). During delete/index timing windows,
113
+ // load can attempt to construct entities from null data.
114
+ if (!handled && operation === 'delete') {
115
+ if (key === 'user') {
116
+ setRepositoryAuthHeaders(null);
117
+ dispatch(setUser(null));
118
+ dispatch(setGroups(null));
119
+ dispatch(setPermissions(null));
120
+ dispatch(setAuthStatus(AUTH_STATUS_UNAUTHENTICATED));
121
+ shouldRecalculateTabVisibility = true;
122
+ } else if (key === 'groups') {
123
+ dispatch(setGroups([]));
124
+ shouldRecalculateTabVisibility = true;
125
+ } else if (key === 'permissions') {
126
+ dispatch(setPermissions([]));
127
+ shouldRecalculateTabVisibility = true;
128
+ }
129
+ handled = true;
130
+ }
131
+
132
+ if (!handled) {
133
+ // Non-delete events: refresh Secure cache before reads.
134
+ const Secure = oneHatData.getRepository('Secure');
135
+ if (Secure) {
136
+ await Secure.load();
137
+ }
138
+
139
+ // USER_CREDS-* is written first on login and contains a complete auth payload.
140
+ // Use it as the fast-path to hydrate user/groups/permissions in one shot.
141
+ if (_.isString(key) && key.startsWith(USER_CREDS)) {
142
+ const parsedCreds = parseSecureCredsPayload(await getSecure(key));
143
+ if (parsedCreds?.user) {
144
+ const groups = _.isNil(parsedCreds.groups) ? await getSecure('groups') : parsedCreds.groups;
145
+ const permissions = _.isNil(parsedCreds.permissions) ? await getSecure('permissions') : parsedCreds.permissions;
146
+ shouldRecalculateTabVisibility = applyCrossTabAuthPayload(dispatch, {
147
+ user: parsedCreds.user,
148
+ groups,
149
+ permissions,
150
+ }) || shouldRecalculateTabVisibility;
151
+ }
152
+ }
153
+
154
+ // User set events may race with index writes; do a one-time delayed retry
155
+ // to pick up groups/permissions that were not index-addressable yet.
156
+ if (key === 'user') {
157
+ const snapshot = await readSecureAuthSnapshot(Secure, true);
158
+ if (snapshot.user) {
159
+ shouldRecalculateTabVisibility = applyCrossTabAuthPayload(dispatch, snapshot) || shouldRecalculateTabVisibility;
160
+ }
161
+ }
162
+
163
+ // Direct key updates: apply whatever is now readable in Secure.
164
+ if (key === 'groups') {
165
+ const groups = await getSecure('groups');
166
+ if (groups !== null) {
167
+ dispatch(setGroups(groups));
168
+ shouldRecalculateTabVisibility = true;
169
+ }
170
+ }
171
+
172
+ if (key === 'permissions') {
173
+ const permissions = await getSecure('permissions');
174
+ if (permissions !== null) {
175
+ dispatch(setPermissions(normalizePermissionsForCrossTab(permissions)));
176
+ shouldRecalculateTabVisibility = true;
177
+ }
178
+ }
179
+
180
+ // Index events are the safety net for set-before-index races.
181
+ // Only backfill user/groups/permissions that are still missing in Redux.
182
+ if (key === 'index') {
183
+ const state = getState();
184
+ const user = await getSecure('user');
185
+ if (user) {
186
+ if (!state?.auth?.user) {
187
+ shouldRecalculateTabVisibility = applyCrossTabAuthPayload(dispatch, { user }) || shouldRecalculateTabVisibility;
188
+ }
189
+
190
+ if (!Array.isArray(state?.auth?.groups)) {
191
+ const groups = await getSecure('groups');
192
+ if (groups) {
193
+ dispatch(setGroups(groups));
194
+ shouldRecalculateTabVisibility = true;
195
+ }
196
+ }
197
+
198
+ if (!Array.isArray(state?.auth?.permissions)) {
199
+ const permissions = await getSecure('permissions');
200
+ if (permissions) {
201
+ dispatch(setPermissions(normalizePermissionsForCrossTab(permissions)));
202
+ shouldRecalculateTabVisibility = true;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ // Optional app callback hook for project-specific behavior.
210
+ if (onCrossTabRehydrateCallback) {
211
+ const callbackResult = await onCrossTabRehydrateCallback({
212
+ key,
213
+ operation,
214
+ dispatch,
215
+ getState,
216
+ shouldRecalculateTabVisibility,
217
+ });
218
+
219
+ if (_.isPlainObject(callbackResult) && !_.isNil(callbackResult.shouldRecalculateTabVisibility)) {
220
+ shouldRecalculateTabVisibility = !!callbackResult.shouldRecalculateTabVisibility;
221
+ }
222
+ }
223
+
224
+ return {
225
+ shouldRecalculateTabVisibility,
226
+ };
227
+ }
228
+ );
229
+
230
+ /**
231
+ * registerCrossTabAuthSync
232
+ *
233
+ * External integration point for crossTabRehydrateThunk, called in initializeApp.
234
+ * This registers one cross-tab listener that routes Secure repository events through it.
235
+ */
236
+ export function registerCrossTabAuthSync(dispatch) {
237
+ oneHatData.on(CROSS_TAB_EVENT_NAME, (data) => {
238
+ if (data?.repositoryName === 'Secure') {
239
+ dispatch(crossTabRehydrateThunk(data));
240
+ }
241
+ });
242
+ }
243
+
244
+ export const forceUnauthenticatedThunk = createAsyncThunk(
245
+ 'auth/forceUnauthenticated',
246
+ async (message = null, { dispatch }) => {
247
+ setRepositoryAuthHeaders(null);
248
+
249
+ dispatch(setUser(null));
250
+ dispatch(setGroups(null));
251
+ dispatch(setPermissions(null));
252
+ dispatch(setAuthStatus(AUTH_STATUS_UNAUTHENTICATED));
253
+
254
+ await deleteSecure('user');
255
+ await deleteSecure('groups');
256
+ await deleteSecure('permissions');
257
+
258
+ if (message) {
259
+ dispatch(setAlertMessage(message));
260
+ }
261
+ }
262
+ );
263
+
264
+ export const refreshAuthTokenThunk = createAsyncThunk(
265
+ 'auth/refreshAuthToken',
266
+ async (unused, { dispatch, getState, rejectWithValue }) => {
267
+ if (refreshAuthTokenPromise) {
268
+ return await refreshAuthTokenPromise;
269
+ }
270
+
271
+ refreshAuthTokenPromise = (async () => {
272
+ const state = getState();
273
+ const currentUser = selectUser(state);
274
+ const currentUserData = getUserData(currentUser);
275
+ const currentToken = getUserToken(currentUserData);
276
+ if (!currentUserData || !currentToken) {
277
+ throw new Error('No authenticated user to refresh.');
278
+ }
279
+
280
+ const Users = oneHatData.getRepository('Users');
281
+ if (!Users) {
282
+ throw new Error('Users repository is not available.');
283
+ }
284
+
285
+ const refreshResult = await Users._send('POST', 'Users/refreshAuthToken', null, {
286
+ transformResponse: null,
287
+ headers: {
288
+ 'Content-Type': 'application/json',
289
+ Accept: 'application/json',
290
+ Authentication: `Token ${currentToken}`,
291
+ },
292
+ timeout: Users.timeout,
293
+ _skipAuthRetry: true,
294
+ });
295
+
296
+ const {
297
+ root,
298
+ success,
299
+ message,
300
+ } = Users._processServerResponse(refreshResult);
301
+ if (!success) {
302
+ throw new Error(message || 'Unable to refresh auth token.');
303
+ }
304
+
305
+ const refreshedRoot = _.isArray(root) ? root[0] : root;
306
+ const nextUserData = _.merge({}, currentUserData, _.isPlainObject(refreshedRoot) ? refreshedRoot : {});
307
+ const nextToken = getUserToken(nextUserData);
308
+ if (!nextToken) {
309
+ throw new Error('refreshAuthToken did not return a token.');
310
+ }
311
+
312
+ if (!nextUserData.users__token) {
313
+ nextUserData.users__token = nextToken;
314
+ }
315
+ if (!nextUserData.token) {
316
+ nextUserData.token = nextToken;
317
+ }
318
+
319
+ await dispatch(setUserThunk(nextUserData));
320
+
321
+ return nextUserData;
322
+ })();
323
+
324
+ try {
325
+ return await refreshAuthTokenPromise;
326
+ } catch (error) {
327
+ return rejectWithValue(error?.message || 'Unable to refresh auth token.');
328
+ } finally {
329
+ refreshAuthTokenPromise = null;
330
+ }
331
+ }
332
+ );
333
+
334
+ export const verifyStartupAuthThunk = createAsyncThunk(
335
+ 'auth/verifyStartupAuth',
336
+ async ({ user, groups, permissions }, { dispatch }) => {
337
+ dispatch(setAuthStatus(AUTH_STATUS_UNKNOWN)); // defensive programming, in case verifyStartupAuthThunk is dispatched multiple times during app startup
338
+
339
+ if (!user) {
340
+ dispatch(setAuthStatus(AUTH_STATUS_UNAUTHENTICATED));
341
+ return false;
342
+ }
343
+
344
+ const userData = getUserData(user);
345
+ const token = getUserToken(userData);
346
+ if (!token) {
347
+ await dispatch(forceUnauthenticatedThunk(EXPIRED_MESSAGE));
348
+ return false;
349
+ }
350
+
351
+ dispatch(setUser(userData));
352
+ setRepositoryAuthHeaders(token);
353
+
354
+ try {
355
+ await dispatch(refreshAuthTokenThunk()).unwrap();
356
+
357
+ if (!_.isNil(groups)) {
358
+ dispatch(setGroups(groups));
359
+ }
360
+ if (!_.isNil(permissions)) {
361
+ dispatch(setPermissions(permissions));
362
+ }
363
+
364
+ dispatch(setAuthStatus(AUTH_STATUS_AUTHENTICATED));
365
+ return true;
366
+ } catch (error) {
367
+ await dispatch(forceUnauthenticatedThunk(EXPIRED_MESSAGE));
368
+ return false;
369
+ }
370
+ }
371
+ );
372
+
373
+
374
+ export const authSlice = createSlice({
375
+ name: 'auth',
376
+ initialState: {
377
+ authStatus: AUTH_STATUS_UNKNOWN,
378
+ user: null,
379
+ groups: null,
380
+ permissions: null,
381
+ },
382
+ reducers: {
383
+ setAuthStatus: (state, action) => {
384
+ state.authStatus = action.payload;
385
+ },
386
+ setUser: (state, action) => {
387
+ state.user = action.payload;
388
+ },
389
+ setGroups: (state, action) => {
390
+ state.groups = action.payload;
391
+ },
392
+ setPermissions: (state, action) => {
393
+ state.permissions = action.payload;
394
+ },
395
+ },
396
+ });
397
+
398
+ export const {
399
+ setAuthStatus,
400
+ setUser,
401
+ setGroups,
402
+ setPermissions,
403
+ } = authSlice.actions;
404
+
405
+ export const selectAuthStatus = state => state.auth.authStatus;
406
+ export const selectUser = state => state.auth.user;
407
+ export const selectGroups = state => state.auth.groups;
408
+ export const selectPermissions = state => state.auth.permissions;
409
+
410
+
411
+ export default authSlice.reducer;
412
+
413
+
414
+
415
+ // ______ ______ __
416
+ // / ____/___ _/ / / /_ ____ ______/ /_______
417
+ // / / / __ `/ / / __ \/ __ `/ ___/ //_/ ___/
418
+ // / /___/ /_/ / / / /_/ / /_/ / /__/ ,< (__ )
419
+ // \____/\__,_/_/_/_.___/\__,_/\___/_/|_/____/
420
+
421
+ // These callbacks allow a project to hook into auth lifecycle events and apply app-specific logic
422
+
423
+ /**
424
+ * Register a callback to be invoked whenever setUserThunk completes.
425
+ * The callback receives the userData object as its argument.
426
+ *
427
+ * @param {Function|null} callback - Async function to call after user is set, or null to clear.
428
+ *
429
+ * USAGE in project:
430
+ *
431
+ // Register callback to be invoked whenever a user is set
432
+ setOnSetUserCallback(async (userData) => {
433
+
434
+ // Do something unique to this app
435
+
436
+ });
437
+ */
438
+ export function setOnSetUserCallback(callback) {
439
+ onSetUserCallback = callback;
440
+ }
441
+
442
+ /**
443
+ * Register a callback to be invoked during cross-tab Secure rehydration.
444
+ *
445
+ * The callback receives an object with:
446
+ * - key
447
+ * - operation
448
+ * - dispatch
449
+ * - getState
450
+ * - shouldRecalculateTabVisibility
451
+ *
452
+ * It may optionally return:
453
+ * - { shouldRecalculateTabVisibility: boolean }
454
+ *
455
+ * @param {Function|null} callback - Async function to call during cross-tab rehydrate, or null to clear.
456
+ *
457
+ * USAGE in project:
458
+ *
459
+ // Register callback to be invoked during cross-tab rehydrate
460
+ setOnCrossTabRehydrateCallback(async ({ key, operation, dispatch, getState, shouldRecalculateTabVisibility }) => {
461
+
462
+ // Do something unique to this app
463
+
464
+ });
465
+ */
466
+ export function setOnCrossTabRehydrateCallback(callback) {
467
+ onCrossTabRehydrateCallback = callback;
468
+ }
469
+
470
+
471
+
472
+ // __ __ __
473
+ // / / / /__ / /___ ___ __________
474
+ // / /_/ / _ \/ / __ \/ _ \/ ___/ ___/
475
+ // / __ / __/ / /_/ / __/ / (__ )
476
+ // /_/ /_/\___/_/ .___/\___/_/ /____/
477
+ // /_/
478
+
479
+ function normalizePermissionKey(permissionKey) {
480
+ // Secure payloads may use DB-style permission keys (e.g. permissions__view_reports).
481
+ // Convert to app-level keys expected by canUser/checkPermission.
482
+ if (!_.isString(permissionKey)) {
483
+ return null;
484
+ }
485
+ return permissionKey.replace(/^permissions__/, '');
486
+ }
487
+
488
+ function normalizePermissionsForCrossTab(permissions) {
489
+ // Cross-tab data can arrive as object maps, arrays of strings, or arrays of objects.
490
+ // Normalize everything into a de-duplicated array of canonical permission strings.
491
+ if (_.isPlainObject(permissions)) {
492
+ return _.chain(permissions)
493
+ .map((value, permissionKey) => parseInt(value) ? normalizePermissionKey(permissionKey) : null)
494
+ .compact()
495
+ .value();
496
+ }
497
+
498
+ if (Array.isArray(permissions)) {
499
+ return _.chain(permissions)
500
+ .map((permission) => {
501
+ if (_.isString(permission)) {
502
+ return normalizePermissionKey(permission);
503
+ }
504
+ if (_.isPlainObject(permission) && _.isString(permission.permission)) {
505
+ return normalizePermissionKey(permission.permission);
506
+ }
507
+ return null;
508
+ })
509
+ .compact()
510
+ .uniq()
511
+ .value();
512
+ }
513
+
514
+ return permissions;
515
+ }
516
+
517
+ function parseSecureCredsPayload(secureCreds) {
518
+ // USER_CREDS-* is stored as a JSON string; parse it safely so a malformed
519
+ // record does not break cross-tab auth rehydration.
520
+ if (_.isString(secureCreds)) {
521
+ try {
522
+ return JSON.parse(secureCreds);
523
+ } catch (error) {
524
+ return null;
525
+ }
526
+ }
527
+ return _.isPlainObject(secureCreds) ? secureCreds : null;
528
+ }
529
+
530
+ function applyCrossTabAuthPayload(dispatch, payload) {
531
+ // Applies the subset of auth data that is present.
532
+ // Returns whether any state changed so caller can decide whether to recalc tabs.
533
+ const {
534
+ user = undefined,
535
+ groups = undefined,
536
+ permissions = undefined,
537
+ } = payload;
538
+
539
+ let changed = false;
540
+
541
+ if (!_.isUndefined(user)) {
542
+ const userData = getUserData(user);
543
+ dispatch(setUser(userData));
544
+ setRepositoryAuthHeaders(getUserToken(userData));
545
+ dispatch(setAuthStatus(AUTH_STATUS_AUTHENTICATED));
546
+ changed = true;
547
+ }
548
+
549
+ if (!_.isUndefined(groups) && groups !== null) {
550
+ dispatch(setGroups(groups));
551
+ changed = true;
552
+ }
553
+
554
+ if (!_.isUndefined(permissions) && permissions !== null) {
555
+ dispatch(setPermissions(normalizePermissionsForCrossTab(permissions)));
556
+ changed = true;
557
+ }
558
+
559
+ return changed;
560
+ }
561
+
562
+ async function readSecureAuthSnapshot(Secure, retryMissing = false) {
563
+ // Reads user/groups/permissions from Secure in one place.
564
+ // Optional retry handles set-before-index races where first read can miss keys.
565
+ const user = await getSecure('user');
566
+ let groups = await getSecure('groups');
567
+ let permissions = await getSecure('permissions');
568
+
569
+ if (retryMissing && (groups === null || permissions === null)) {
570
+ await new Promise(resolve => setTimeout(resolve, 75));
571
+ if (Secure) {
572
+ await Secure.load();
573
+ }
574
+
575
+ if (groups === null) {
576
+ groups = await getSecure('groups');
577
+ }
578
+ if (permissions === null) {
579
+ permissions = await getSecure('permissions');
580
+ }
581
+ }
582
+
583
+ return { user, groups, permissions };
584
+ }