@squiz/resource-browser 3.1.2 → 3.2.1
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/CHANGELOG.md +12 -0
- package/lib/Hooks/useAuth.js +37 -9
- package/lib/index.css +6 -0
- package/lib/index.js +1 -11
- package/lib/utils/authUtils.js +7 -2
- package/package.json +1 -1
- package/src/Hooks/useAuth.spec.tsx +100 -0
- package/src/Hooks/useAuth.ts +40 -9
- package/src/index.spec.tsx +48 -0
- package/src/index.tsx +1 -12
- package/src/utils/authUtils.spec.ts +17 -0
- package/src/utils/authUtils.ts +6 -2
package/CHANGELOG.md
CHANGED
package/lib/Hooks/useAuth.js
CHANGED
@@ -6,20 +6,27 @@ const react_1 = require("react");
|
|
6
6
|
const authUtils_1 = require("../utils/authUtils");
|
7
7
|
const useAuth = (authConfig) => {
|
8
8
|
const [authToken, setAuthToken] = (0, react_1.useState)((0, authUtils_1.getCookieValue)('authToken'));
|
9
|
-
const [isAuthenticated, setIsAuthenticated] = (0, react_1.useState)(!!
|
9
|
+
const [isAuthenticated, setIsAuthenticated] = (0, react_1.useState)(!!authToken);
|
10
10
|
const refreshAccessToken = (0, react_1.useCallback)(async () => {
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
11
|
+
try {
|
12
|
+
const newToken = await (0, authUtils_1.refreshAccessToken)(authConfig);
|
13
|
+
setAuthToken(newToken);
|
14
|
+
setIsAuthenticated(!!newToken);
|
15
|
+
return newToken;
|
16
|
+
}
|
17
|
+
catch {
|
18
|
+
setAuthToken(null);
|
19
|
+
setIsAuthenticated(false);
|
20
|
+
throw new Error('Session expired. Please log in again.');
|
21
|
+
}
|
15
22
|
}, [authConfig]);
|
16
23
|
const handleLogin = (0, react_1.useCallback)(() => {
|
17
|
-
if (!authConfig?.redirectUrl
|
24
|
+
if (!authConfig?.redirectUrl || !authConfig?.authUrl) {
|
18
25
|
console.error('Auth config is misconfigured');
|
19
26
|
return;
|
20
27
|
}
|
21
28
|
const encodedRedirectUrl = encodeURIComponent(authConfig.redirectUrl);
|
22
|
-
const scope = authConfig?.scope
|
29
|
+
const scope = authConfig?.scope?.split(';').join(' '); // Saved in scope1;scope2;scope3 format, sent in scope1 scope2 scope3 format
|
23
30
|
const loginUrl = `${authConfig?.authUrl}?client_id=${authConfig?.clientId}&scope=${scope}&redirect_uri=${encodedRedirectUrl}&response_type=code&state=state`;
|
24
31
|
const popup = window.open(loginUrl, 'Login', 'width=600,height=600');
|
25
32
|
if (!popup) {
|
@@ -28,10 +35,12 @@ const useAuth = (authConfig) => {
|
|
28
35
|
}
|
29
36
|
const checkPopup = setInterval(() => {
|
30
37
|
try {
|
31
|
-
|
38
|
+
const token = (0, authUtils_1.getCookieValue)('authToken');
|
39
|
+
const refreshToken = (0, authUtils_1.getCookieValue)('refreshToken');
|
40
|
+
if (token && refreshToken) {
|
32
41
|
clearInterval(checkPopup);
|
33
42
|
popup.close();
|
34
|
-
setAuthToken(
|
43
|
+
setAuthToken(token);
|
35
44
|
setIsAuthenticated(true);
|
36
45
|
}
|
37
46
|
}
|
@@ -44,6 +53,25 @@ const useAuth = (authConfig) => {
|
|
44
53
|
}
|
45
54
|
}, 1000); // Check every second
|
46
55
|
}, [authConfig]);
|
56
|
+
const syncAuthState = () => {
|
57
|
+
const token = (0, authUtils_1.getCookieValue)('authToken');
|
58
|
+
setAuthToken(token);
|
59
|
+
setIsAuthenticated(!!token);
|
60
|
+
};
|
61
|
+
(0, react_1.useEffect)(() => {
|
62
|
+
const handleTabChange = () => {
|
63
|
+
if (!document.hidden) {
|
64
|
+
syncAuthState();
|
65
|
+
}
|
66
|
+
};
|
67
|
+
syncAuthState();
|
68
|
+
document.addEventListener('visibilitychange', handleTabChange);
|
69
|
+
window.addEventListener('focus', syncAuthState);
|
70
|
+
return () => {
|
71
|
+
document.removeEventListener('visibilitychange', handleTabChange);
|
72
|
+
window.removeEventListener('focus', syncAuthState);
|
73
|
+
};
|
74
|
+
}, []);
|
47
75
|
(0, react_1.useEffect)(() => {
|
48
76
|
refreshAccessToken().catch(() => {
|
49
77
|
setIsAuthenticated(false);
|
package/lib/index.css
CHANGED
@@ -4805,6 +4805,9 @@
|
|
4805
4805
|
white-space: nowrap;
|
4806
4806
|
border-width: 0;
|
4807
4807
|
}
|
4808
|
+
.squiz-rb-scope .visible:not(.squiz-rb-plugin *) {
|
4809
|
+
visibility: visible;
|
4810
|
+
}
|
4808
4811
|
.squiz-rb-scope .fixed:not(.squiz-rb-plugin *) {
|
4809
4812
|
position: fixed;
|
4810
4813
|
}
|
@@ -5663,6 +5666,9 @@
|
|
5663
5666
|
border: none;
|
5664
5667
|
padding: 0;
|
5665
5668
|
}
|
5669
|
+
.squiz-rb-scope .image-card:disabled:not(.squiz-rb-plugin *) {
|
5670
|
+
cursor: not-allowed;
|
5671
|
+
}
|
5666
5672
|
.squiz-rb-scope .image-card--selected:not(.squiz-rb-plugin *) {
|
5667
5673
|
background-color: #e6f1fa;
|
5668
5674
|
}
|
package/lib/index.js
CHANGED
@@ -55,7 +55,7 @@ const ResourceBrowser = (props) => {
|
|
55
55
|
const [source, setSource] = (0, react_1.useState)(null);
|
56
56
|
const [mode, setMode] = (0, react_1.useState)(null);
|
57
57
|
const { data: sources, isLoading, error: sourcesError, reload: reloadSources } = (0, useSources_1.useSources)({ onRequestSources, plugins });
|
58
|
-
const
|
58
|
+
const plugin = source?.plugin || null;
|
59
59
|
// MainContainer will render a list of sources of one is not provided to it, callback to allow it to set the source once a user selects
|
60
60
|
const handleSourceSelect = (0, react_1.useCallback)((source, mode) => {
|
61
61
|
setSource(source);
|
@@ -82,16 +82,6 @@ const ResourceBrowser = (props) => {
|
|
82
82
|
setSource(source);
|
83
83
|
setMode(null); // Passed in resource will always use the default mode
|
84
84
|
}, [value, isLoading, sources, setSource, setError]);
|
85
|
-
// When a source is selected update our plugin reference to match (legacy support)
|
86
|
-
// the plugin is now attached to the source directly when fetched from the context so use that instead when possible
|
87
|
-
(0, react_1.useEffect)(() => {
|
88
|
-
if (source?.plugin) {
|
89
|
-
setPlugin(source.plugin);
|
90
|
-
}
|
91
|
-
else {
|
92
|
-
setPlugin(null);
|
93
|
-
}
|
94
|
-
}, [plugins, source]);
|
95
85
|
// The modal has some control over it own open/closed state (for WCAG reasons) so keep this in sync with our state
|
96
86
|
const handleModalStateChange = (0, react_1.useCallback)((isOpen) => {
|
97
87
|
setIsModalOpen(isOpen);
|
package/lib/utils/authUtils.js
CHANGED
@@ -2,8 +2,13 @@
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
3
3
|
exports.refreshAccessToken = exports.logout = exports.setCookieValue = exports.getCookieValue = void 0;
|
4
4
|
const getCookieValue = (name) => {
|
5
|
-
|
6
|
-
|
5
|
+
try {
|
6
|
+
const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
7
|
+
return match ? match.pop() : null;
|
8
|
+
}
|
9
|
+
catch (error) {
|
10
|
+
return null;
|
11
|
+
}
|
7
12
|
};
|
8
13
|
exports.getCookieValue = getCookieValue;
|
9
14
|
const setCookieValue = (name, value) => {
|
package/package.json
CHANGED
@@ -194,4 +194,104 @@ describe('useAuth', () => {
|
|
194
194
|
expect(result.current.isAuthenticated).toBe(false);
|
195
195
|
});
|
196
196
|
});
|
197
|
+
|
198
|
+
it('should update state when the page gains focus', async () => {
|
199
|
+
const { result } = renderHook(() => useAuth(authConfig));
|
200
|
+
|
201
|
+
mockGetCookieValue.mockReturnValueOnce('focusedAuthToken');
|
202
|
+
|
203
|
+
act(() => {
|
204
|
+
window.dispatchEvent(new Event('focus'));
|
205
|
+
});
|
206
|
+
|
207
|
+
await waitFor(() => {
|
208
|
+
expect(result.current.authToken).toBe('focusedAuthToken');
|
209
|
+
expect(result.current.isAuthenticated).toBe(true);
|
210
|
+
});
|
211
|
+
});
|
212
|
+
|
213
|
+
it('should update state when the document becomes visible', async () => {
|
214
|
+
mockGetCookieValue.mockReturnValueOnce(null);
|
215
|
+
const { result } = renderHook(() => useAuth(authConfig));
|
216
|
+
|
217
|
+
mockGetCookieValue.mockReturnValue('visibleAuthToken');
|
218
|
+
|
219
|
+
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
|
220
|
+
|
221
|
+
act(() => {
|
222
|
+
document.dispatchEvent(new Event('visibilitychange'));
|
223
|
+
});
|
224
|
+
|
225
|
+
await waitFor(() => {
|
226
|
+
expect(result.current.authToken).toBe('visibleAuthToken');
|
227
|
+
expect(result.current.isAuthenticated).toBe(true);
|
228
|
+
});
|
229
|
+
});
|
230
|
+
|
231
|
+
it('should clean up event listeners on unmount', async () => {
|
232
|
+
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
233
|
+
|
234
|
+
const { unmount } = renderHook(() => useAuth(authConfig));
|
235
|
+
|
236
|
+
unmount();
|
237
|
+
|
238
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function));
|
239
|
+
});
|
240
|
+
|
241
|
+
it('should log error when authConfig is misconfigured', async () => {
|
242
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
243
|
+
|
244
|
+
const invalidConfig = { clientId: 'example-client-id', scope: 'offline' };
|
245
|
+
|
246
|
+
// @ts-ignore
|
247
|
+
const { result } = renderHook(() => useAuth(invalidConfig));
|
248
|
+
|
249
|
+
result.current.login();
|
250
|
+
|
251
|
+
await waitFor(() => {
|
252
|
+
expect(consoleSpy).toHaveBeenCalledWith('Auth config is misconfigured');
|
253
|
+
});
|
254
|
+
|
255
|
+
consoleSpy.mockRestore();
|
256
|
+
});
|
257
|
+
|
258
|
+
it('should handle missing refreshToken when logging in', async () => {
|
259
|
+
jest.useFakeTimers();
|
260
|
+
|
261
|
+
const popupMock = {
|
262
|
+
closed: false,
|
263
|
+
close: jest.fn(),
|
264
|
+
} as unknown as Window;
|
265
|
+
|
266
|
+
jest.spyOn(window, 'open').mockImplementation(() => popupMock);
|
267
|
+
mockGetCookieValue.mockReturnValueOnce(null).mockReturnValueOnce('newAuthToken').mockReturnValueOnce(null);
|
268
|
+
|
269
|
+
const { result } = renderHook(() => useAuth(authConfig));
|
270
|
+
|
271
|
+
result.current.login();
|
272
|
+
|
273
|
+
act(() => {
|
274
|
+
mockGetCookieValue.mockReturnValue('newAuthToken');
|
275
|
+
jest.advanceTimersByTime(1000);
|
276
|
+
(popupMock as any).closed = true;
|
277
|
+
});
|
278
|
+
|
279
|
+
await waitFor(() => {
|
280
|
+
expect(result.current.authToken).toBe(null);
|
281
|
+
expect(result.current.isAuthenticated).toBe(false);
|
282
|
+
});
|
283
|
+
|
284
|
+
expect(popupMock.close).toHaveBeenCalled();
|
285
|
+
});
|
286
|
+
|
287
|
+
it('should throw an error when getCookieValue throws unexpectedly', () => {
|
288
|
+
jest.spyOn(authUtils, 'getCookieValue').mockImplementation(() => {
|
289
|
+
throw new Error('Unexpected error');
|
290
|
+
});
|
291
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
292
|
+
|
293
|
+
expect(() => renderHook(() => useAuth(authConfig))).toThrow('Unexpected error');
|
294
|
+
|
295
|
+
consoleSpy.mockRestore();
|
296
|
+
});
|
197
297
|
});
|
package/src/Hooks/useAuth.ts
CHANGED
@@ -5,22 +5,28 @@ import { getCookieValue, refreshAccessToken as refreshTokenUtil } from '../utils
|
|
5
5
|
|
6
6
|
export const useAuth = (authConfig: AuthenticationConfiguration | undefined) => {
|
7
7
|
const [authToken, setAuthToken] = useState<string | null>(getCookieValue('authToken'));
|
8
|
-
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!
|
8
|
+
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!authToken);
|
9
9
|
|
10
10
|
const refreshAccessToken = useCallback(async (): Promise<string> => {
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
11
|
+
try {
|
12
|
+
const newToken = await refreshTokenUtil(authConfig);
|
13
|
+
setAuthToken(newToken);
|
14
|
+
setIsAuthenticated(!!newToken);
|
15
|
+
return newToken;
|
16
|
+
} catch {
|
17
|
+
setAuthToken(null);
|
18
|
+
setIsAuthenticated(false);
|
19
|
+
throw new Error('Session expired. Please log in again.');
|
20
|
+
}
|
15
21
|
}, [authConfig]);
|
16
22
|
|
17
23
|
const handleLogin = useCallback((): void => {
|
18
|
-
if (!authConfig?.redirectUrl
|
24
|
+
if (!authConfig?.redirectUrl || !authConfig?.authUrl) {
|
19
25
|
console.error('Auth config is misconfigured');
|
20
26
|
return;
|
21
27
|
}
|
22
28
|
const encodedRedirectUrl = encodeURIComponent(authConfig.redirectUrl);
|
23
|
-
const scope = authConfig?.scope
|
29
|
+
const scope = authConfig?.scope?.split(';').join(' '); // Saved in scope1;scope2;scope3 format, sent in scope1 scope2 scope3 format
|
24
30
|
const loginUrl = `${authConfig?.authUrl}?client_id=${authConfig?.clientId}&scope=${scope}&redirect_uri=${encodedRedirectUrl}&response_type=code&state=state`;
|
25
31
|
const popup = window.open(loginUrl, 'Login', 'width=600,height=600');
|
26
32
|
|
@@ -31,10 +37,12 @@ export const useAuth = (authConfig: AuthenticationConfiguration | undefined) =>
|
|
31
37
|
|
32
38
|
const checkPopup = setInterval(() => {
|
33
39
|
try {
|
34
|
-
|
40
|
+
const token = getCookieValue('authToken');
|
41
|
+
const refreshToken = getCookieValue('refreshToken');
|
42
|
+
if (token && refreshToken) {
|
35
43
|
clearInterval(checkPopup);
|
36
44
|
popup.close();
|
37
|
-
setAuthToken(
|
45
|
+
setAuthToken(token);
|
38
46
|
setIsAuthenticated(true);
|
39
47
|
}
|
40
48
|
} catch (error) {
|
@@ -48,6 +56,29 @@ export const useAuth = (authConfig: AuthenticationConfiguration | undefined) =>
|
|
48
56
|
}, 1000); // Check every second
|
49
57
|
}, [authConfig]);
|
50
58
|
|
59
|
+
const syncAuthState = () => {
|
60
|
+
const token = getCookieValue('authToken');
|
61
|
+
setAuthToken(token);
|
62
|
+
setIsAuthenticated(!!token);
|
63
|
+
};
|
64
|
+
|
65
|
+
useEffect(() => {
|
66
|
+
const handleTabChange = () => {
|
67
|
+
if (!document.hidden) {
|
68
|
+
syncAuthState();
|
69
|
+
}
|
70
|
+
};
|
71
|
+
syncAuthState();
|
72
|
+
|
73
|
+
document.addEventListener('visibilitychange', handleTabChange);
|
74
|
+
window.addEventListener('focus', syncAuthState);
|
75
|
+
|
76
|
+
return () => {
|
77
|
+
document.removeEventListener('visibilitychange', handleTabChange);
|
78
|
+
window.removeEventListener('focus', syncAuthState);
|
79
|
+
};
|
80
|
+
}, []);
|
81
|
+
|
51
82
|
useEffect(() => {
|
52
83
|
refreshAccessToken().catch(() => {
|
53
84
|
setIsAuthenticated(false);
|
package/src/index.spec.tsx
CHANGED
@@ -666,5 +666,53 @@ describe('Resource browser input', () => {
|
|
666
666
|
);
|
667
667
|
});
|
668
668
|
});
|
669
|
+
|
670
|
+
it('switching source updates plugin and does not use the old plugin', async () => {
|
671
|
+
const damSource = mockSource({ type: 'dam', id: '1' });
|
672
|
+
const matrixSource = mockSource({ type: 'matrix', id: '2' });
|
673
|
+
|
674
|
+
const pluginA = { ...mockDamPlugin, type: 'dam' };
|
675
|
+
const pluginB = { ...mockDamPlugin, type: 'matrix' };
|
676
|
+
|
677
|
+
mockRequestSources.mockResolvedValueOnce([damSource, matrixSource]);
|
678
|
+
|
679
|
+
renderComponent();
|
680
|
+
|
681
|
+
const { setSource } = (RBI.ResourceBrowserInput as jest.Mock).mock.calls[0][0];
|
682
|
+
|
683
|
+
act(() => {
|
684
|
+
setSource({ ...damSource, plugin: pluginA });
|
685
|
+
});
|
686
|
+
|
687
|
+
await waitFor(() => {
|
688
|
+
expect(RBI.ResourceBrowserInput).toHaveBeenCalledWith(
|
689
|
+
expect.objectContaining({
|
690
|
+
plugin: pluginA,
|
691
|
+
}),
|
692
|
+
expect.any(Object),
|
693
|
+
);
|
694
|
+
});
|
695
|
+
|
696
|
+
(Plugin.PluginRender as jest.Mock).mockClear();
|
697
|
+
|
698
|
+
act(() => {
|
699
|
+
setSource({ ...matrixSource, plugin: pluginB });
|
700
|
+
});
|
701
|
+
|
702
|
+
await waitFor(() => {
|
703
|
+
expect(Plugin.PluginRender).toHaveBeenCalledWith(
|
704
|
+
expect.objectContaining({
|
705
|
+
plugin: pluginB,
|
706
|
+
}),
|
707
|
+
expect.any(Object),
|
708
|
+
);
|
709
|
+
expect(Plugin.PluginRender).not.toHaveBeenCalledWith(
|
710
|
+
expect.objectContaining({
|
711
|
+
plugin: pluginA,
|
712
|
+
}),
|
713
|
+
expect.any(Object),
|
714
|
+
);
|
715
|
+
});
|
716
|
+
});
|
669
717
|
});
|
670
718
|
});
|
package/src/index.tsx
CHANGED
@@ -6,7 +6,6 @@ import {
|
|
6
6
|
PluginLaunchMode,
|
7
7
|
ResourceBrowserUnresolvedResource,
|
8
8
|
ResourceBrowserResource,
|
9
|
-
ResourceBrowserPlugin,
|
10
9
|
ResourceBrowserSourceWithPlugin,
|
11
10
|
} from './types';
|
12
11
|
import { useSources } from './Hooks/useSources';
|
@@ -49,7 +48,7 @@ export const ResourceBrowser = (props: ResourceBrowserProps) => {
|
|
49
48
|
const [source, setSource] = useState<ResourceBrowserSourceWithPlugin | null>(null);
|
50
49
|
const [mode, setMode] = useState<PluginLaunchMode | null>(null);
|
51
50
|
const { data: sources, isLoading, error: sourcesError, reload: reloadSources } = useSources({ onRequestSources, plugins });
|
52
|
-
const
|
51
|
+
const plugin = source?.plugin || null;
|
53
52
|
|
54
53
|
// MainContainer will render a list of sources of one is not provided to it, callback to allow it to set the source once a user selects
|
55
54
|
const handleSourceSelect = useCallback(
|
@@ -83,16 +82,6 @@ export const ResourceBrowser = (props: ResourceBrowserProps) => {
|
|
83
82
|
setMode(null); // Passed in resource will always use the default mode
|
84
83
|
}, [value, isLoading, sources, setSource, setError]);
|
85
84
|
|
86
|
-
// When a source is selected update our plugin reference to match (legacy support)
|
87
|
-
// the plugin is now attached to the source directly when fetched from the context so use that instead when possible
|
88
|
-
useEffect(() => {
|
89
|
-
if (source?.plugin) {
|
90
|
-
setPlugin(source.plugin);
|
91
|
-
} else {
|
92
|
-
setPlugin(null);
|
93
|
-
}
|
94
|
-
}, [plugins, source]);
|
95
|
-
|
96
85
|
// The modal has some control over it own open/closed state (for WCAG reasons) so keep this in sync with our state
|
97
86
|
const handleModalStateChange = useCallback(
|
98
87
|
(isOpen: boolean) => {
|
@@ -23,6 +23,23 @@ describe('auth-utils', () => {
|
|
23
23
|
it('should return null if the cookie does not exist', () => {
|
24
24
|
expect(getCookieValue('nonExistentCookie')).toBeNull();
|
25
25
|
});
|
26
|
+
|
27
|
+
it('should return null if an error occurs when reading cookies', () => {
|
28
|
+
const originalCookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
|
29
|
+
|
30
|
+
Object.defineProperty(document, 'cookie', {
|
31
|
+
get: () => {
|
32
|
+
throw new Error('Cookie read error');
|
33
|
+
},
|
34
|
+
configurable: true,
|
35
|
+
});
|
36
|
+
|
37
|
+
expect(getCookieValue('anyCookie')).toBeNull();
|
38
|
+
|
39
|
+
if (originalCookieDescriptor) {
|
40
|
+
Object.defineProperty(document, 'cookie', originalCookieDescriptor);
|
41
|
+
}
|
42
|
+
});
|
26
43
|
});
|
27
44
|
|
28
45
|
describe('setCookieValue', () => {
|
package/src/utils/authUtils.ts
CHANGED
@@ -1,8 +1,12 @@
|
|
1
1
|
import { AuthenticationConfiguration } from '../types';
|
2
2
|
|
3
3
|
export const getCookieValue = (name: string): string | null => {
|
4
|
-
|
5
|
-
|
4
|
+
try {
|
5
|
+
const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
6
|
+
return match ? match.pop()! : null;
|
7
|
+
} catch (error) {
|
8
|
+
return null;
|
9
|
+
}
|
6
10
|
};
|
7
11
|
|
8
12
|
export const setCookieValue = (name: string, value: string): void => {
|