@onehat/ui 0.4.109 → 0.4.112
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/Hoc/withPermissions.js +1 -1
- package/src/Components/Layout/UserIndicator.js +3 -1
- package/src/Constants/Auth.js +3 -0
- package/src/Functions/authFunctions.js +98 -0
- package/src/Functions/getReport.js +7 -2
- package/src/Functions/getTokenHeaders.js +3 -0
- package/src/Hooks/useCrossTabSecureSync.js +37 -0
- package/src/Models/Slices/AuthSlice.js +584 -0
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ import _ from 'lodash';
|
|
|
23
23
|
export function checkPermission(permission) {
|
|
24
24
|
const
|
|
25
25
|
reduxState = UiGlobals.redux?.getState(),
|
|
26
|
-
permissions = reduxState?.app?.permissions || [];
|
|
26
|
+
permissions = reduxState?.app?.permissions || reduxState?.auth?.permissions || [];
|
|
27
27
|
let hasPermission = inArray(permission, permissions);
|
|
28
28
|
if (hasPermission) {
|
|
29
29
|
return true;
|
|
@@ -8,8 +8,10 @@ import clsx from 'clsx';
|
|
|
8
8
|
import { useSelector, useDispatch } from 'react-redux';
|
|
9
9
|
import {
|
|
10
10
|
logout,
|
|
11
|
-
selectUser,
|
|
12
11
|
} from '@src/Models/Slices/AppSlice';
|
|
12
|
+
import {
|
|
13
|
+
selectUser,
|
|
14
|
+
} from '../../Models/Slices/AuthSlice.js';
|
|
13
15
|
import IconButton from '../Buttons/IconButton';
|
|
14
16
|
import RightFromBracket from '../Icons/RightFromBracket';
|
|
15
17
|
import User from '../Icons/User';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import UiGlobals from '../UiGlobals.js';
|
|
2
|
+
import oneHatData from '@onehat/data';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* getTokenHeaders
|
|
7
|
+
*
|
|
8
|
+
* Main entry point for building an Authentication header object.
|
|
9
|
+
* If `clearAll` is true, returns a header with Authentication: null to wipe the token (e.g. on logout).
|
|
10
|
+
* Accepts an optional `user` arg (a plain object or @onehat/data Entity); falls back to Redux state if omitted.
|
|
11
|
+
*
|
|
12
|
+
* @param {boolean} [clearAll=false] - When true, returns a header that clears the token (Authentication: null).
|
|
13
|
+
* @param {object|null} [user=null] - A plain user object or @onehat/data Entity. Falls back to Redux state if null.
|
|
14
|
+
* @returns {object} Header object with an `Authentication` key.
|
|
15
|
+
*/
|
|
16
|
+
export default function getTokenHeaders(clearAll = false, user = null) {
|
|
17
|
+
if (clearAll) {
|
|
18
|
+
return getRepositoryAuthHeaders(null);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const
|
|
22
|
+
reduxState = UiGlobals?.redux?.getState ? UiGlobals.redux.getState() : null,
|
|
23
|
+
scopedUser = user || reduxState?.app?.user,
|
|
24
|
+
token = getUserToken(scopedUser);
|
|
25
|
+
|
|
26
|
+
if (!token) {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return getRepositoryAuthHeaders(token);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* getUserData
|
|
35
|
+
*
|
|
36
|
+
* Normalizes a user value into a plain data object.
|
|
37
|
+
* Exists because a user may arrive as either a raw plain object or a @onehat/data Entity.
|
|
38
|
+
*
|
|
39
|
+
* @param {object|null} user - A plain user object or @onehat/data Entity.
|
|
40
|
+
* @returns {object|null} The raw user data object, or null if no user was provided.
|
|
41
|
+
*/
|
|
42
|
+
export function getUserData(user) {
|
|
43
|
+
if (!user) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return user?.getOriginalData ? user.getOriginalData() : user;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* getUserToken
|
|
51
|
+
*
|
|
52
|
+
* Extracts the auth token string from a user value.
|
|
53
|
+
* Exists because the token may be stored under either `token` or `users__token` depending on the API response shape.
|
|
54
|
+
* Normalizes the user via getUserData first, then returns whichever key is present, or null.
|
|
55
|
+
*
|
|
56
|
+
* @param {object|null} user - A plain user object or @onehat/data Entity.
|
|
57
|
+
* @returns {string|null} The token string, or null if none is found.
|
|
58
|
+
*/
|
|
59
|
+
export function getUserToken(user) {
|
|
60
|
+
const userData = getUserData(user);
|
|
61
|
+
return userData?.token || userData?.users__token || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* getRepositoryAuthHeaders
|
|
66
|
+
*
|
|
67
|
+
* Builds the raw header object used for authenticated API requests.
|
|
68
|
+
* Exists as a dedicated function so the header shape is defined in one place and can be used
|
|
69
|
+
* both for one-off header construction and for pushing headers to all repositories.
|
|
70
|
+
* Passing null (or omitting the arg) sets Authentication to null, clearing the token.
|
|
71
|
+
*
|
|
72
|
+
* @param {string|null} [token=null] - The auth token string. Pass null to clear the token.
|
|
73
|
+
* @returns {object} Header object with an `Authentication` key.
|
|
74
|
+
*/
|
|
75
|
+
export function getRepositoryAuthHeaders(token = null) {
|
|
76
|
+
return {
|
|
77
|
+
Authentication: token ? `Token ${token}` : null,
|
|
78
|
+
// Cookie: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* setRepositoryAuthHeaders
|
|
84
|
+
*
|
|
85
|
+
* Propagates an auth token to every @onehat/data repository in one call.
|
|
86
|
+
* Exists to support the refresh token flow: when a new token is obtained, this immediately
|
|
87
|
+
* updates all repository instances and the repository globals so subsequent requests use the new token.
|
|
88
|
+
* Passing null clears the token everywhere (e.g. on logout).
|
|
89
|
+
*
|
|
90
|
+
* @param {string|null} [token=null] - The auth token string. Pass null to clear the token on all repositories.
|
|
91
|
+
* @returns {object} The header object that was applied to all repositories.
|
|
92
|
+
*/
|
|
93
|
+
export function setRepositoryAuthHeaders(token = null) {
|
|
94
|
+
const headers = getRepositoryAuthHeaders(token);
|
|
95
|
+
oneHatData.setOptionsOnAllRepositories({ headers });
|
|
96
|
+
oneHatData.setRepositoryGlobals({ headers });
|
|
97
|
+
return headers;
|
|
98
|
+
}
|
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
} from '../Constants/ReportTypes.js';
|
|
5
5
|
import downloadInBackground from './downloadInBackground.js';
|
|
6
6
|
import downloadWithFetch from './downloadWithFetch.js';
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
getUserToken,
|
|
9
|
+
getRepositoryAuthHeaders,
|
|
10
|
+
} from './authFunctions.js';
|
|
8
11
|
import UiGlobals from '../UiGlobals.js';
|
|
9
12
|
|
|
10
13
|
export default function getReport(args) {
|
|
@@ -27,7 +30,9 @@ export default function getReport(args) {
|
|
|
27
30
|
showReportHeaders,
|
|
28
31
|
...data,
|
|
29
32
|
},
|
|
30
|
-
|
|
33
|
+
user = UiGlobals?.redux?.getState ? UiGlobals.redux.getState()?.auth?.user : null,
|
|
34
|
+
token = getUserToken(user),
|
|
35
|
+
authHeaders = getRepositoryAuthHeaders(token);
|
|
31
36
|
|
|
32
37
|
if (reportType === REPORT_TYPES__EXCEL) {
|
|
33
38
|
downloadInBackground(url, params, authHeaders);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import UiGlobals from '../UiGlobals.js';
|
|
2
2
|
|
|
3
3
|
export default function(clearAll = false) {
|
|
4
|
+
|
|
5
|
+
throw Error('getTokenHeaders is deprecated. Please use the new functions in authFunctions.js instead.');
|
|
6
|
+
|
|
4
7
|
const reduxState = UiGlobals.redux.getState();
|
|
5
8
|
if (!reduxState.app.user || (!reduxState.app.user.token && !reduxState.app.user.users__token)) {
|
|
6
9
|
return {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import oneHatData from '@onehat/data';
|
|
3
|
+
import { CROSS_TAB_EVENT_NAME } from '@onehat/data/src/Integration/Browser/Repository/crossTabConstants.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscribes to cross-tab storage change events emitted by the Secure repository.
|
|
7
|
+
* When another tab writes to the Secure store, `onChange` is called with:
|
|
8
|
+
* { operation, key, namespacedKey, timestamp, repositoryName, repositoryType }
|
|
9
|
+
*
|
|
10
|
+
* The callback is intentionally free of Redux/app concerns — wire your own
|
|
11
|
+
* re-hydration logic (e.g. re-dispatch setUserThunk) in the callback.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* useCrossTabSecureSync(useCallback(async ({ key }) => {
|
|
15
|
+
* if (key === 'user') {
|
|
16
|
+
* const user = await getSecure('user');
|
|
17
|
+
* dispatch(setUserThunk(user));
|
|
18
|
+
* }
|
|
19
|
+
* }, [dispatch]));
|
|
20
|
+
*
|
|
21
|
+
* @param {Function} onChange - Stable callback (wrap in useCallback).
|
|
22
|
+
* @param {string} [repositoryName='Secure'] - Override target repository name.
|
|
23
|
+
*/
|
|
24
|
+
export default function useCrossTabSecureSync(onChange, repositoryName = 'Secure') {
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handler = (data) => {
|
|
27
|
+
if (data?.repositoryName !== repositoryName) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
onChange(data);
|
|
31
|
+
};
|
|
32
|
+
oneHatData.on(CROSS_TAB_EVENT_NAME, handler);
|
|
33
|
+
return () => {
|
|
34
|
+
oneHatData.off(CROSS_TAB_EVENT_NAME, handler);
|
|
35
|
+
};
|
|
36
|
+
}, [onChange, repositoryName]);
|
|
37
|
+
}
|
|
@@ -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
|
+
}
|