@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.
- package/package.json +1 -1
- package/src/Components/Form/Field/Tag/Tag.js +1 -1
- package/src/Components/Form/Form.js +31 -12
- package/src/Components/Hoc/withPdfButtons.js +2 -2
- package/src/Components/Hoc/withPermissions.js +1 -1
- package/src/Components/Hoc/withPresetButtons.js +6 -2
- package/src/Components/Layout/UserIndicator.js +3 -1
- package/src/Components/Viewer/Viewer.js +20 -12
- package/src/Components/Window/UploadsDownloadsWindow.js +30 -12
- package/src/Constants/Auth.js +3 -0
- package/src/Functions/authFunctions.js +98 -0
- package/src/Functions/getTokenHeaders.js +3 -0
- package/src/Hooks/useCrossTabSecureSync.js +37 -0
- package/src/Models/Slices/AuthSlice.js +584 -0
- package/src/Components/Form/Field/FormikForm.js +0 -1019
|
@@ -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
|
+
}
|