@pushwoosh/smart-components-kit 1.3.22 → 1.3.23
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/ProductUpdates/ProductUpdates.js +7 -3
- package/ProductUpdates/hooks/useProductUpdates.d.ts +4 -0
- package/ProductUpdates/hooks/useProductUpdates.js +12 -3
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +2 -1
- package/staffImpersonation/index.d.ts +1 -0
- package/staffImpersonation/index.js +1 -0
- package/staffImpersonation/isStaffImpersonation.d.ts +5 -0
- package/staffImpersonation/isStaffImpersonation.js +13 -0
- package/staffImpersonation/isStaffImpersonation.test.d.ts +1 -0
- package/staffImpersonation/isStaffImpersonation.test.js +49 -0
- package/staffImpersonation/useIsStaffImpersonation.d.ts +7 -0
- package/staffImpersonation/useIsStaffImpersonation.js +13 -0
|
@@ -19,16 +19,20 @@ export function ProductUpdates({
|
|
|
19
19
|
isLoading,
|
|
20
20
|
hasUpdates,
|
|
21
21
|
markAllRead,
|
|
22
|
-
clearNewBadges
|
|
22
|
+
clearNewBadges,
|
|
23
|
+
isImpersonation,
|
|
24
|
+
isImpersonationResolved
|
|
23
25
|
} = useProductUpdates();
|
|
26
|
+
// Staff in impersonation must not auto-open: the modal would spend the client's read marks.
|
|
27
|
+
// Unresolved means the token is still being read, so hold rather than open and find out later.
|
|
24
28
|
useEffect(() => {
|
|
25
|
-
if (!isAllowed || !autoOpenOnUnread || hasAutoOpened.current || unreadCount === 0) {
|
|
29
|
+
if (!isAllowed || !autoOpenOnUnread || hasAutoOpened.current || unreadCount === 0 || !isImpersonationResolved || isImpersonation) {
|
|
26
30
|
return;
|
|
27
31
|
}
|
|
28
32
|
hasAutoOpened.current = true;
|
|
29
33
|
setIsOpen(true);
|
|
30
34
|
markAllRead();
|
|
31
|
-
}, [isAllowed, autoOpenOnUnread, unreadCount, markAllRead]);
|
|
35
|
+
}, [isAllowed, autoOpenOnUnread, unreadCount, markAllRead, isImpersonation, isImpersonationResolved]);
|
|
32
36
|
const handleOpen = () => {
|
|
33
37
|
setIsOpen(true);
|
|
34
38
|
markAllRead();
|
|
@@ -8,6 +8,10 @@ type UseProductUpdates = {
|
|
|
8
8
|
readonly markAllRead: () => Promise<void>;
|
|
9
9
|
/** Drops the NEW badges and the unread counter. Call on close, so they last while reading. */
|
|
10
10
|
readonly clearNewBadges: () => void;
|
|
11
|
+
/** Staff viewing a client account from the super admin: no marks are written for them. */
|
|
12
|
+
readonly isImpersonation: boolean;
|
|
13
|
+
/** False until the access token is read, so an auto-open effect can hold instead of racing it. */
|
|
14
|
+
readonly isImpersonationResolved: boolean;
|
|
11
15
|
};
|
|
12
16
|
export declare function useProductUpdates(): UseProductUpdates;
|
|
13
17
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from 'react';
|
|
2
2
|
import { useAsync } from '@pushwoosh/kit-helpers';
|
|
3
3
|
import { useSPAContext } from '@pushwoosh/spa-context';
|
|
4
|
+
import { useIsStaffImpersonation } from '../../staffImpersonation';
|
|
4
5
|
import { ALERT_STATUS_READ, ALERT_STATUS_UNREAD, ALERT_TYPE_PRODUCT_UPDATE, ORDER_BY_CREATED, ORDER_DIRECTION_DESC, MAX_VISIBLE_UPDATES } from '../constants';
|
|
5
6
|
import { useIsAllowProductUpdates } from './useIsAllowProductUpdates';
|
|
6
7
|
const EMPTY = [];
|
|
@@ -9,6 +10,10 @@ export function useProductUpdates() {
|
|
|
9
10
|
rpcGateway
|
|
10
11
|
} = useSPAContext();
|
|
11
12
|
const isAllowed = useIsAllowProductUpdates();
|
|
13
|
+
const {
|
|
14
|
+
isImpersonation,
|
|
15
|
+
isResolved
|
|
16
|
+
} = useIsStaffImpersonation();
|
|
12
17
|
const [areBadgesCleared, setAreBadgesCleared] = useState(false);
|
|
13
18
|
const hasSentRef = useRef(false);
|
|
14
19
|
const state = useAsync(async () => {
|
|
@@ -32,7 +37,9 @@ export function useProductUpdates() {
|
|
|
32
37
|
// The mark goes out on open, not on close: a reload with the modal still open is not
|
|
33
38
|
// a close, and the same updates used to pop again.
|
|
34
39
|
const markAllRead = useCallback(async () => {
|
|
35
|
-
|
|
40
|
+
// Read marks belong to the client's user, so staff writes none, and an unresolved check holds
|
|
41
|
+
// the mark rather than guessing. The flag is set before the first await: two calls send once.
|
|
42
|
+
if (hasSentRef.current || isImpersonation || !isResolved) {
|
|
36
43
|
return;
|
|
37
44
|
}
|
|
38
45
|
const unread = fetched.filter(update => update.status === ALERT_STATUS_UNREAD);
|
|
@@ -51,7 +58,7 @@ export function useProductUpdates() {
|
|
|
51
58
|
console.error('Failed to mark product updates as read:', error);
|
|
52
59
|
hasSentRef.current = false;
|
|
53
60
|
}
|
|
54
|
-
}, [rpcGateway, fetched]);
|
|
61
|
+
}, [rpcGateway, fetched, isImpersonation, isResolved]);
|
|
55
62
|
const clearNewBadges = useCallback(() => setAreBadgesCleared(true), []);
|
|
56
63
|
return {
|
|
57
64
|
updates,
|
|
@@ -59,6 +66,8 @@ export function useProductUpdates() {
|
|
|
59
66
|
isLoading: state.loading,
|
|
60
67
|
hasUpdates: updates.length > 0,
|
|
61
68
|
markAllRead,
|
|
62
|
-
clearNewBadges
|
|
69
|
+
clearNewBadges,
|
|
70
|
+
isImpersonation,
|
|
71
|
+
isImpersonationResolved: isResolved
|
|
63
72
|
};
|
|
64
73
|
}
|
package/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from './EmailTemplatePreview';
|
|
|
7
7
|
export * from './ExportCSV';
|
|
8
8
|
export * from './PlatformsConfigurationDrawer';
|
|
9
9
|
export * from './ProductUpdates';
|
|
10
|
+
export * from './staffImpersonation';
|
|
10
11
|
export * from './LanguagesSelector';
|
|
11
12
|
export * from './MediaStorage';
|
|
12
13
|
export * from './mmsAttachments';
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from './EmailTemplatePreview';
|
|
|
7
7
|
export * from './ExportCSV';
|
|
8
8
|
export * from './PlatformsConfigurationDrawer';
|
|
9
9
|
export * from './ProductUpdates';
|
|
10
|
+
export * from './staffImpersonation';
|
|
10
11
|
export * from './LanguagesSelector';
|
|
11
12
|
export * from './MediaStorage';
|
|
12
13
|
export * from './mmsAttachments';
|
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"@pushwoosh/rpc-v2-http-api-data": "^0.2.151",
|
|
34
34
|
"@pushwoosh/spa-context": "^1.7.1",
|
|
35
35
|
"idb-keyval": "^6.2.2",
|
|
36
|
+
"jwt-decode": "^4.0.0",
|
|
36
37
|
"react-hook-form": "^7.68.0",
|
|
37
38
|
"react-router-dom": "^5.3.4"
|
|
38
39
|
},
|
|
@@ -54,5 +55,5 @@
|
|
|
54
55
|
"react": "^16.14.0 || ^18.0.0",
|
|
55
56
|
"react-dom": "^16.14.0 || ^18.0.0"
|
|
56
57
|
},
|
|
57
|
-
"version": "1.3.
|
|
58
|
+
"version": "1.3.23"
|
|
58
59
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useIsStaffImpersonation } from './useIsStaffImpersonation';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useIsStaffImpersonation } from './useIsStaffImpersonation';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jwtDecode } from 'jwt-decode';
|
|
2
|
+
// Staff coming from the super admin gets is_su with no parent user; a client switched into
|
|
3
|
+
// their own child account gets is_su too, but with the parent user id filled in.
|
|
4
|
+
export async function isStaffImpersonation(httpClient) {
|
|
5
|
+
try {
|
|
6
|
+
const claims = jwtDecode(await httpClient.getAccessToken());
|
|
7
|
+
const hasParentUser = claims.parent_user_id != null && claims.parent_user_id !== '0';
|
|
8
|
+
return claims.is_su === true && !hasParentUser;
|
|
9
|
+
} catch {
|
|
10
|
+
// Fail open: a client must not lose the feature because the token could not be read.
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isStaffImpersonation } from './isStaffImpersonation';
|
|
3
|
+
function tokenWith(claims) {
|
|
4
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
5
|
+
return `eyJhbGciOiJub25lIn0.${payload}.signature`;
|
|
6
|
+
}
|
|
7
|
+
function clientWith(token) {
|
|
8
|
+
return {
|
|
9
|
+
getAccessToken: async () => {
|
|
10
|
+
if (token instanceof Error) {
|
|
11
|
+
throw token;
|
|
12
|
+
}
|
|
13
|
+
return token;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('isStaffImpersonation', () => {
|
|
18
|
+
it('is false for a plain client login', async () => {
|
|
19
|
+
await expect(isStaffImpersonation(clientWith(tokenWith({
|
|
20
|
+
account_id: 42
|
|
21
|
+
})))).resolves.toBe(false);
|
|
22
|
+
});
|
|
23
|
+
it('is true for staff impersonation without parent_user_id', async () => {
|
|
24
|
+
await expect(isStaffImpersonation(clientWith(tokenWith({
|
|
25
|
+
is_su: true
|
|
26
|
+
})))).resolves.toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('is true for staff impersonation with parent_user_id "0"', async () => {
|
|
29
|
+
await expect(isStaffImpersonation(clientWith(tokenWith({
|
|
30
|
+
is_su: true,
|
|
31
|
+
parent_user_id: '0'
|
|
32
|
+
})))).resolves.toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('is false for a client switched into their own child account', async () => {
|
|
35
|
+
await expect(isStaffImpersonation(clientWith(tokenWith({
|
|
36
|
+
is_su: true,
|
|
37
|
+
parent_user_id: '77123'
|
|
38
|
+
})))).resolves.toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it('is false when the token carries no claims at all', async () => {
|
|
41
|
+
await expect(isStaffImpersonation(clientWith(tokenWith({})))).resolves.toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it('is false for a garbage token', async () => {
|
|
44
|
+
await expect(isStaffImpersonation(clientWith('not-a-jwt'))).resolves.toBe(false);
|
|
45
|
+
});
|
|
46
|
+
it('is false when there is no token to read', async () => {
|
|
47
|
+
await expect(isStaffImpersonation(clientWith(new Error('Unauthorized!')))).resolves.toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type UseIsStaffImpersonation = {
|
|
2
|
+
readonly isImpersonation: boolean;
|
|
3
|
+
/** False until the token is read, so a caller can hold an effect instead of racing it. */
|
|
4
|
+
readonly isResolved: boolean;
|
|
5
|
+
};
|
|
6
|
+
export declare function useIsStaffImpersonation(): UseIsStaffImpersonation;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useAsync } from '@pushwoosh/kit-helpers';
|
|
2
|
+
import { useSPAContext } from '@pushwoosh/spa-context';
|
|
3
|
+
import { isStaffImpersonation } from './isStaffImpersonation';
|
|
4
|
+
export function useIsStaffImpersonation() {
|
|
5
|
+
const {
|
|
6
|
+
httpClient
|
|
7
|
+
} = useSPAContext();
|
|
8
|
+
const state = useAsync(() => isStaffImpersonation(httpClient), [httpClient]);
|
|
9
|
+
return {
|
|
10
|
+
isImpersonation: state.value ?? false,
|
|
11
|
+
isResolved: !state.loading
|
|
12
|
+
};
|
|
13
|
+
}
|