@squiz/resource-browser 2.1.10-rc.0 → 2.2.0-rc.0

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 CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [2.2.0-rc.0](https://gitlab.squiz.net/dxp/dxp-shared-ui/resource-browser/compare/@squiz/resource-browser@2.1.10-rc.0...@squiz/resource-browser@2.2.0-rc.0) (2024-06-06)
7
+
8
+ ### Features
9
+
10
+ - **PRODAM:92:** auth provider ([3c7136d](https://gitlab.squiz.net/dxp/dxp-shared-ui/resource-browser/commit/3c7136da5f5ef148f713ad6a39f5d2328eaff4c7))
11
+
6
12
  ## [2.1.10-rc.0](https://gitlab.squiz.net/dxp/dxp-shared-ui/resource-browser/compare/@squiz/resource-browser@2.1.9-rc.0...@squiz/resource-browser@2.1.10-rc.0) (2024-06-06)
7
13
 
8
14
  **Note:** Version bump only for package @squiz/resource-browser
@@ -0,0 +1,7 @@
1
+ import { AuthenticationConfiguration } from '../types';
2
+ export declare const useAuth: (authConfig: AuthenticationConfiguration | undefined) => {
3
+ authToken: string | null;
4
+ isAuthenticated: boolean;
5
+ login: () => void;
6
+ refreshAccessToken: () => Promise<string>;
7
+ };
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useAuth = void 0;
4
+ const react_1 = require("react");
5
+ const authUtils_1 = require("../utils/authUtils");
6
+ const useAuth = (authConfig) => {
7
+ const [authToken, setAuthToken] = (0, react_1.useState)((0, authUtils_1.getCookieValue)('authToken'));
8
+ const [isAuthenticated, setIsAuthenticated] = (0, react_1.useState)(!!(0, authUtils_1.getCookieValue)('authToken'));
9
+ const refreshAccessToken = (0, react_1.useCallback)(async () => {
10
+ const newToken = await (0, authUtils_1.refreshAccessToken)(authConfig);
11
+ setAuthToken(newToken);
12
+ setIsAuthenticated(!!newToken);
13
+ return newToken;
14
+ }, [authConfig]);
15
+ const handleLogin = (0, react_1.useCallback)(() => {
16
+ if (!authConfig?.redirectUrl && !authConfig?.authUrl && !authConfig?.redirectUrl && !authConfig?.scope) {
17
+ return;
18
+ }
19
+ const encodedRedirectUrl = encodeURIComponent(authConfig?.redirectUrl || '');
20
+ const loginUrl = `${authConfig?.authUrl}?client_id=${authConfig?.clientId}&scope=${authConfig?.scope}&redirect_uri=${encodedRedirectUrl}&response_type=code&state=state`;
21
+ const popup = window.open(loginUrl, 'Login', 'width=600,height=600');
22
+ if (!popup) {
23
+ console.error('Popup failed to open');
24
+ return;
25
+ }
26
+ const checkPopup = setInterval(() => {
27
+ try {
28
+ if ((0, authUtils_1.getCookieValue)('authToken') && (0, authUtils_1.getCookieValue)('refreshToken')) {
29
+ clearInterval(checkPopup);
30
+ popup.close();
31
+ setAuthToken((0, authUtils_1.getCookieValue)('authToken'));
32
+ setIsAuthenticated(true);
33
+ }
34
+ }
35
+ catch (error) {
36
+ // Ignore cross-origin access errors
37
+ }
38
+ if (popup.closed) {
39
+ clearInterval(checkPopup);
40
+ console.error('Popup closed before authentication');
41
+ }
42
+ }, 1000); // Check every second
43
+ }, [authConfig]);
44
+ (0, react_1.useEffect)(() => {
45
+ refreshAccessToken().catch(() => {
46
+ setIsAuthenticated(false);
47
+ });
48
+ }, [authConfig, refreshAccessToken]);
49
+ return {
50
+ authToken,
51
+ isAuthenticated,
52
+ login: handleLogin,
53
+ refreshAccessToken,
54
+ };
55
+ };
56
+ exports.useAuth = useAuth;
@@ -6,9 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.PluginRender = void 0;
7
7
  const react_1 = __importDefault(require("react"));
8
8
  const ResourceBrowserInput_1 = require("../ResourceBrowserInput/ResourceBrowserInput");
9
+ const AuthProvider_1 = require("../ResourceBrowserContext/AuthProvider");
9
10
  const PluginRender = ({ render, ...props }) => {
10
11
  if (render) {
11
- return react_1.default.createElement(ResourceBrowserInput_1.ResourceBrowserInput, { ...props });
12
+ return react_1.default.createElement(AuthProvider_1.AuthProvider, { authConfig: props.source },
13
+ react_1.default.createElement(ResourceBrowserInput_1.ResourceBrowserInput, { ...props }));
12
14
  }
13
15
  else {
14
16
  return react_1.default.createElement(react_1.default.Fragment, null);
@@ -0,0 +1,16 @@
1
+ import React, { ReactNode } from 'react';
2
+ import { ResourceBrowserSource } from '../types';
3
+ interface AuthContextProps {
4
+ authToken: string | null;
5
+ isAuthenticated: boolean;
6
+ login: () => void;
7
+ refreshAccessToken: () => Promise<any>;
8
+ }
9
+ export declare const AuthContext: React.Context<AuthContextProps | undefined>;
10
+ export declare const useAuthContext: () => AuthContextProps;
11
+ interface AuthProviderProps {
12
+ children: ReactNode;
13
+ authConfig?: ResourceBrowserSource | null;
14
+ }
15
+ export declare const AuthProvider: ({ children, authConfig }: AuthProviderProps) => React.JSX.Element;
16
+ export {};
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.AuthProvider = exports.useAuthContext = exports.AuthContext = void 0;
27
+ const react_1 = __importStar(require("react"));
28
+ const useAuth_1 = require("../Hooks/useAuth");
29
+ exports.AuthContext = (0, react_1.createContext)(undefined);
30
+ const useAuthContext = () => {
31
+ const context = (0, react_1.useContext)(exports.AuthContext);
32
+ if (!context) {
33
+ throw new Error('useAuthContext must be used within an AuthProvider');
34
+ }
35
+ return context;
36
+ };
37
+ exports.useAuthContext = useAuthContext;
38
+ const AuthProvider = ({ children, authConfig }) => {
39
+ const authConfiguration = authConfig;
40
+ const auth = (0, useAuth_1.useAuth)(authConfiguration?.configuration);
41
+ if (!authConfiguration?.configuration) {
42
+ return react_1.default.createElement(react_1.default.Fragment, null, children);
43
+ }
44
+ return (react_1.default.createElement(exports.AuthContext.Provider, { value: auth }, children));
45
+ };
46
+ exports.AuthProvider = AuthProvider;
package/lib/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
2
  import { ResourceBrowserContext, ResourceBrowserContextProvider } from './ResourceBrowserContext/ResourceBrowserContext';
3
3
  import { ResourceBrowserUnresolvedResource, ResourceBrowserResource } from './types';
4
- export { ResourceBrowserContext, ResourceBrowserContextProvider };
4
+ import { AuthProvider, useAuthContext, AuthContext } from './ResourceBrowserContext/AuthProvider';
5
+ export { ResourceBrowserContext, ResourceBrowserContextProvider, useAuthContext, AuthProvider, AuthContext };
5
6
  export * from './types';
6
7
  export type ResourceBrowserProps = {
7
8
  modalTitle: string;
package/lib/index.js CHANGED
@@ -26,13 +26,17 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
26
26
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
27
27
  };
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.ResourceBrowser = exports.ResourceBrowserContextProvider = exports.ResourceBrowserContext = void 0;
29
+ exports.ResourceBrowser = exports.AuthContext = exports.AuthProvider = exports.useAuthContext = exports.ResourceBrowserContextProvider = exports.ResourceBrowserContext = void 0;
30
30
  const react_1 = __importStar(require("react"));
31
31
  const ResourceBrowserContext_1 = require("./ResourceBrowserContext/ResourceBrowserContext");
32
32
  Object.defineProperty(exports, "ResourceBrowserContext", { enumerable: true, get: function () { return ResourceBrowserContext_1.ResourceBrowserContext; } });
33
33
  Object.defineProperty(exports, "ResourceBrowserContextProvider", { enumerable: true, get: function () { return ResourceBrowserContext_1.ResourceBrowserContextProvider; } });
34
34
  const useSources_1 = require("./Hooks/useSources");
35
35
  const Plugin_1 = require("./Plugin/Plugin");
36
+ const AuthProvider_1 = require("./ResourceBrowserContext/AuthProvider");
37
+ Object.defineProperty(exports, "AuthProvider", { enumerable: true, get: function () { return AuthProvider_1.AuthProvider; } });
38
+ Object.defineProperty(exports, "useAuthContext", { enumerable: true, get: function () { return AuthProvider_1.useAuthContext; } });
39
+ Object.defineProperty(exports, "AuthContext", { enumerable: true, get: function () { return AuthProvider_1.AuthContext; } });
36
40
  __exportStar(require("./types"), exports);
37
41
  const ResourceBrowser = (props) => {
38
42
  const { value } = props;
package/lib/types.d.ts CHANGED
@@ -2,11 +2,20 @@ import React, { ReactElement } from 'react';
2
2
  import { SquizImageType } from '@squiz/dx-json-schema-lib';
3
3
  export type OnRequestSources = () => Promise<ResourceBrowserSource[]>;
4
4
  export type ResourceBrowserPluginType = 'dam' | 'matrix';
5
- export type ResourceBrowserSource = {
5
+ export type AuthenticationConfiguration = {
6
+ authUrl: string;
7
+ redirectUrl: string;
8
+ clientId: string;
9
+ scope: string;
10
+ };
11
+ export interface ResourceBrowserSource {
6
12
  name?: string;
7
13
  id: string;
8
14
  type: ResourceBrowserPluginType;
9
- };
15
+ }
16
+ export interface ResourceBrowserSourceWithConfig extends ResourceBrowserSource {
17
+ configuration?: AuthenticationConfiguration;
18
+ }
10
19
  export type ResourceBrowserUnresolvedResource = {
11
20
  sourceId: string;
12
21
  resourceId: string;
@@ -0,0 +1,5 @@
1
+ import { AuthenticationConfiguration } from '../types';
2
+ export declare const getCookieValue: (name: string) => string | null;
3
+ export declare const setCookieValue: (name: string, value: string) => void;
4
+ export declare const logout: () => void;
5
+ export declare const refreshAccessToken: (authConfig?: AuthenticationConfiguration) => Promise<string>;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refreshAccessToken = exports.logout = exports.setCookieValue = exports.getCookieValue = void 0;
4
+ const getCookieValue = (name) => {
5
+ const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
6
+ return match ? match.pop() : null;
7
+ };
8
+ exports.getCookieValue = getCookieValue;
9
+ const setCookieValue = (name, value) => {
10
+ document.cookie = `${name}=${value}; Path=/;`;
11
+ };
12
+ exports.setCookieValue = setCookieValue;
13
+ const logout = () => {
14
+ (0, exports.setCookieValue)('authToken', '');
15
+ (0, exports.setCookieValue)('refreshToken', '');
16
+ };
17
+ exports.logout = logout;
18
+ const refreshAccessToken = async (authConfig) => {
19
+ if (!authConfig) {
20
+ throw new Error('No auth configuration available');
21
+ }
22
+ const refreshToken = (0, exports.getCookieValue)('refreshToken');
23
+ if (!refreshToken) {
24
+ throw new Error('You are not logged in');
25
+ }
26
+ const response = await fetch(`${authConfig.redirectUrl}?grant_type=refresh_token&refresh_token=${refreshToken}`, {
27
+ method: 'GET',
28
+ credentials: 'include',
29
+ });
30
+ if (!response.ok) {
31
+ (0, exports.logout)();
32
+ throw new Error('Failed to refresh token');
33
+ }
34
+ const data = await response.json();
35
+ (0, exports.setCookieValue)('authToken', data.access_token);
36
+ return data.access_token;
37
+ };
38
+ exports.refreshAccessToken = refreshAccessToken;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/resource-browser",
3
- "version": "2.1.10-rc.0",
3
+ "version": "2.2.0-rc.0",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "private": false,
@@ -81,5 +81,5 @@
81
81
  "volta": {
82
82
  "node": "18.18.0"
83
83
  },
84
- "gitHead": "0799116e7501bb4eaa3c401be6304231d8910f75"
84
+ "gitHead": "32bbc578ba11464c0c79e2714fdbe50890329be4"
85
85
  }
@@ -0,0 +1,137 @@
1
+ import { renderHook, act, waitFor } from '@testing-library/react';
2
+ import { useAuth } from './useAuth';
3
+ import '@testing-library/jest-dom';
4
+ import { AuthenticationConfiguration } from '../types';
5
+ import * as authUtils from '../utils/authUtils';
6
+
7
+ jest.mock('../utils/authUtils');
8
+
9
+ const mockGetCookieValue = authUtils.getCookieValue as jest.MockedFunction<typeof authUtils.getCookieValue>;
10
+ const mockRefreshAccessToken = authUtils.refreshAccessToken as jest.MockedFunction<typeof authUtils.refreshAccessToken>;
11
+
12
+ describe('useAuth', () => {
13
+ const authConfig: AuthenticationConfiguration = {
14
+ authUrl: 'https://auth.example.com',
15
+ clientId: 'example-client-id',
16
+ redirectUrl: 'https://example.com/callback',
17
+ scope: 'offline_access'
18
+ };
19
+
20
+ beforeEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+
24
+ it('should initialize with token and authentication state', async () => {
25
+ mockGetCookieValue.mockReturnValue('initialAuthToken');
26
+
27
+ const { result } = renderHook(() => useAuth(authConfig));
28
+
29
+ await waitFor(() => {
30
+ expect(result.current.authToken).toBe('initialAuthToken');
31
+ expect(result.current.isAuthenticated).toBe(true);
32
+ });
33
+ });
34
+
35
+ it('should log error and return when popup fails to open', async () => {
36
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
37
+ jest.spyOn(window, 'open').mockImplementation(() => null);
38
+
39
+ const { result } = renderHook(() => useAuth(authConfig));
40
+
41
+ result.current.login();
42
+
43
+ await waitFor(() => {
44
+ expect(consoleSpy).toHaveBeenCalledWith('Popup failed to open');
45
+ });
46
+ consoleSpy.mockRestore();
47
+ });
48
+
49
+ it('should log error when popup closes before authentication', async () => {
50
+ jest.useFakeTimers();
51
+
52
+ const popupMock = {
53
+ close: jest.fn(),
54
+ } as unknown as Window;
55
+
56
+ Object.defineProperty(popupMock, 'closed', {
57
+ get: jest.fn(() => false),
58
+ set: jest.fn()
59
+ });
60
+
61
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
62
+ jest.spyOn(window, 'open').mockImplementation(() => popupMock);
63
+ mockGetCookieValue.mockReturnValueOnce(null);
64
+
65
+ const { result } = renderHook(() => useAuth(authConfig));
66
+
67
+ result.current.login();
68
+ (Object.getOwnPropertyDescriptor(popupMock, 'closed')!.get as jest.Mock).mockReturnValue(true);
69
+ jest.advanceTimersByTime(1000);
70
+
71
+ await waitFor(() => {
72
+ expect(consoleSpy).toHaveBeenCalledWith('Popup closed before authentication');
73
+ });
74
+
75
+ consoleSpy.mockRestore();
76
+ });
77
+
78
+ it('should login successfully and update state', async () => {
79
+ jest.useFakeTimers();
80
+
81
+ const popupMock = {
82
+ closed: false,
83
+ close: jest.fn(),
84
+ } as unknown as Window;
85
+
86
+ jest.spyOn(window, 'open').mockImplementation(() => popupMock);
87
+ mockGetCookieValue.mockReturnValueOnce(null).mockReturnValueOnce('newAuthToken').mockReturnValueOnce('newRefreshToken');
88
+
89
+ const { result } = renderHook(() => useAuth(authConfig));
90
+
91
+ result.current.login();
92
+
93
+ expect(window.open).toHaveBeenCalledWith(
94
+ `${authConfig.authUrl}?client_id=${authConfig.clientId}&scope=offline_access&redirect_uri=${encodeURIComponent(authConfig.redirectUrl)}&response_type=code&state=state`,
95
+ 'Login',
96
+ 'width=600,height=600'
97
+ );
98
+
99
+ act(() => {
100
+ mockGetCookieValue.mockReturnValue('newAuthToken');
101
+ jest.advanceTimersByTime(1000);
102
+ (popupMock as any).closed = true;
103
+ });
104
+
105
+ await waitFor(() => {
106
+ expect(result.current.authToken).toBe('newAuthToken');
107
+ expect(result.current.isAuthenticated).toBe(true);
108
+ });
109
+
110
+ expect(popupMock.close).toHaveBeenCalled();
111
+ });
112
+
113
+ it('should refresh access token and update state', async () => {
114
+ mockGetCookieValue.mockReturnValue('initialRefreshToken');
115
+ mockRefreshAccessToken.mockResolvedValue('newAuthToken');
116
+
117
+ const { result } = renderHook(() => useAuth(authConfig));
118
+
119
+ await waitFor(() => {
120
+ expect(mockRefreshAccessToken).toHaveBeenCalledWith(authConfig);
121
+ expect(result.current.authToken).toBe('newAuthToken');
122
+ expect(result.current.isAuthenticated).toBe(true);
123
+ });
124
+ });
125
+
126
+ it('should handle token refresh failure', async () => {
127
+ mockGetCookieValue.mockReturnValue('initialRefreshToken');
128
+ mockRefreshAccessToken.mockRejectedValue(new Error('Failed to refresh token'));
129
+
130
+ const { result } = renderHook(() => useAuth(authConfig));
131
+
132
+ await waitFor(() => {
133
+ expect(mockRefreshAccessToken).toHaveBeenCalledWith(authConfig);
134
+ expect(result.current.isAuthenticated).toBe(false);
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,60 @@
1
+ import { useState, useCallback, useEffect } from 'react';
2
+ import { AuthenticationConfiguration } from '../types';
3
+ import { getCookieValue, refreshAccessToken as refreshTokenUtil } from '../utils/authUtils';
4
+
5
+ export const useAuth = (authConfig: AuthenticationConfiguration | undefined) => {
6
+ const [authToken, setAuthToken] = useState<string | null>(getCookieValue('authToken'));
7
+ const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!getCookieValue('authToken'));
8
+
9
+ const refreshAccessToken = useCallback(async (): Promise<string> => {
10
+ const newToken = await refreshTokenUtil(authConfig);
11
+ setAuthToken(newToken);
12
+ setIsAuthenticated(!!newToken);
13
+ return newToken;
14
+ }, [authConfig]);
15
+
16
+ const handleLogin = useCallback((): void => {
17
+ if (!authConfig?.redirectUrl && !authConfig?.authUrl && !authConfig?.redirectUrl && !authConfig?.scope) {
18
+ return;
19
+ }
20
+ const encodedRedirectUrl = encodeURIComponent(authConfig?.redirectUrl || '');
21
+ const loginUrl = `${authConfig?.authUrl}?client_id=${authConfig?.clientId}&scope=${authConfig?.scope}&redirect_uri=${encodedRedirectUrl}&response_type=code&state=state`;
22
+ const popup = window.open(loginUrl, 'Login', 'width=600,height=600');
23
+
24
+ if (!popup) {
25
+ console.error('Popup failed to open');
26
+ return;
27
+ }
28
+
29
+ const checkPopup = setInterval(() => {
30
+ try {
31
+ if (getCookieValue('authToken') && getCookieValue('refreshToken')) {
32
+ clearInterval(checkPopup);
33
+ popup.close();
34
+ setAuthToken(getCookieValue('authToken'));
35
+ setIsAuthenticated(true);
36
+ }
37
+ } catch (error) {
38
+ // Ignore cross-origin access errors
39
+ }
40
+
41
+ if (popup.closed) {
42
+ clearInterval(checkPopup);
43
+ console.error('Popup closed before authentication');
44
+ }
45
+ }, 1000); // Check every second
46
+ }, [authConfig]);
47
+
48
+ useEffect(() => {
49
+ refreshAccessToken().catch(() => {
50
+ setIsAuthenticated(false);
51
+ });
52
+ }, [authConfig, refreshAccessToken]);
53
+
54
+ return {
55
+ authToken,
56
+ isAuthenticated,
57
+ login: handleLogin,
58
+ refreshAccessToken,
59
+ };
60
+ };
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { ResourceBrowserInput, ResourceBrowserInputProps } from '../ResourceBrowserInput/ResourceBrowserInput';
3
+ import { AuthProvider } from '../ResourceBrowserContext/AuthProvider';
3
4
 
4
5
  /**
5
6
  * This plugin component exsits to deal with React rules of Hooks stupidity.
@@ -13,7 +14,7 @@ export type PluginRenderType = ResourceBrowserInputProps & {
13
14
  };
14
15
  export const PluginRender = ({ render, ...props }: PluginRenderType) => {
15
16
  if (render) {
16
- return <ResourceBrowserInput {...props} />;
17
+ return <AuthProvider authConfig={props.source}><ResourceBrowserInput {...props} /></AuthProvider>;
17
18
  } else {
18
19
  return <></>;
19
20
  }
@@ -0,0 +1,73 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import { AuthProvider, useAuthContext } from './AuthProvider';
4
+ import {AuthenticationConfiguration, ResourceBrowserSourceWithConfig} from '../types';
5
+ import { useAuth } from '../Hooks/useAuth';
6
+
7
+ jest.mock('../Hooks/useAuth');
8
+
9
+ const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
10
+
11
+ describe('AuthContext', () => {
12
+ const authConfig: AuthenticationConfiguration = {
13
+ authUrl: 'https://auth.example.com',
14
+ clientId: 'example-client-id',
15
+ redirectUrl: 'https://example.com/callback',
16
+ scope: 'offline'
17
+ };
18
+
19
+ const sourceConfig: ResourceBrowserSourceWithConfig = {
20
+ id: '1',
21
+ type: 'dam',
22
+ configuration: authConfig
23
+ };
24
+
25
+ const authState = {
26
+ authToken: 'testAuthToken',
27
+ isAuthenticated: true,
28
+ login: jest.fn(),
29
+ refreshAccessToken: jest.fn(),
30
+ };
31
+
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ mockUseAuth.mockReturnValue(authState);
35
+ });
36
+
37
+ it('should provide auth state and functions to consumers', async () => {
38
+ const TestComponent = () => {
39
+ const { authToken, isAuthenticated, login } = useAuthContext();
40
+ return (
41
+ <div>
42
+ <span>{`Token: ${authToken}`}</span>
43
+ <span>{`Authenticated: ${isAuthenticated}`}</span>
44
+ <button onClick={login}>Login</button>
45
+ </div>
46
+ );
47
+ };
48
+
49
+ render(
50
+ <AuthProvider authConfig={sourceConfig}>
51
+ <TestComponent />
52
+ </AuthProvider>
53
+ );
54
+
55
+ expect(screen.getByText(/Token: testAuthToken/)).toBeInTheDocument();
56
+ expect(screen.getByText(/Authenticated: true/)).toBeInTheDocument();
57
+
58
+ fireEvent.click(screen.getByText('Login'));
59
+
60
+ expect(authState.login).toHaveBeenCalled();
61
+ });
62
+
63
+ it('should throw an error if used outside of AuthProvider', () => {
64
+ jest.spyOn(console, 'error').mockImplementation(() => {});
65
+
66
+ const TestComponent = () => {
67
+ useAuthContext();
68
+ return <div />;
69
+ };
70
+
71
+ expect(() => render(<TestComponent />)).toThrowError('useAuthContext must be used within an AuthProvider');
72
+ });
73
+ });
@@ -0,0 +1,40 @@
1
+ import React, { createContext, useContext, ReactNode } from 'react';
2
+ import { useAuth } from '../Hooks/useAuth';
3
+ import { ResourceBrowserSource, ResourceBrowserSourceWithConfig } from '../types';
4
+
5
+ interface AuthContextProps {
6
+ authToken: string | null;
7
+ isAuthenticated: boolean;
8
+ login: () => void;
9
+ refreshAccessToken: () => Promise<any>;
10
+ }
11
+
12
+ export const AuthContext = createContext<AuthContextProps | undefined>(undefined);
13
+
14
+ export const useAuthContext = (): AuthContextProps => {
15
+ const context = useContext(AuthContext);
16
+ if (!context) {
17
+ throw new Error('useAuthContext must be used within an AuthProvider');
18
+ }
19
+ return context;
20
+ };
21
+
22
+ interface AuthProviderProps {
23
+ children: ReactNode;
24
+ authConfig?: ResourceBrowserSource | null;
25
+ }
26
+
27
+ export const AuthProvider = ( {children, authConfig}: AuthProviderProps ) => {
28
+ const authConfiguration = authConfig as ResourceBrowserSourceWithConfig;
29
+ const auth = useAuth(authConfiguration?.configuration);
30
+
31
+ if (!authConfiguration?.configuration) {
32
+ return <>{children}</>;
33
+ }
34
+
35
+ return (
36
+ <AuthContext.Provider value={auth}>
37
+ {children}
38
+ </AuthContext.Provider>
39
+ );
40
+ };
@@ -267,7 +267,9 @@ describe('Resource browser input', () => {
267
267
 
268
268
  // Invoke modal close callback
269
269
  const { onModalStateChange } = (RBI.ResourceBrowserInput as unknown as jest.SpyInstance).mock.calls[0][0];
270
- onModalStateChange(false);
270
+ act(() => {
271
+ onModalStateChange(false);
272
+ });
271
273
 
272
274
  await waitFor(() => {
273
275
  expect(RBI.ResourceBrowserInput).toHaveBeenCalledWith(
package/src/index.tsx CHANGED
@@ -4,8 +4,9 @@ import { ResourceBrowserContext, ResourceBrowserContextProvider } from './Resour
4
4
  import { ResourceBrowserSource, ResourceBrowserUnresolvedResource, ResourceBrowserResource, ResourceBrowserPlugin } from './types';
5
5
  import { useSources } from './Hooks/useSources';
6
6
  import { PluginRender } from './Plugin/Plugin';
7
+ import { AuthProvider, useAuthContext, AuthContext } from './ResourceBrowserContext/AuthProvider';
7
8
 
8
- export { ResourceBrowserContext, ResourceBrowserContextProvider };
9
+ export { ResourceBrowserContext, ResourceBrowserContextProvider, useAuthContext, AuthProvider, AuthContext };
9
10
  export * from './types';
10
11
 
11
12
  export type ResourceBrowserProps = {
@@ -121,6 +122,7 @@ export const ResourceBrowser = (props: ResourceBrowserProps) => {
121
122
  />
122
123
  );
123
124
  })}
125
+
124
126
  </div>
125
127
  );
126
128
  };
package/src/types.ts CHANGED
@@ -4,14 +4,25 @@ import { SquizImageType } from '@squiz/dx-json-schema-lib';
4
4
  export type OnRequestSources = () => Promise<ResourceBrowserSource[]>;
5
5
  export type ResourceBrowserPluginType = 'dam' | 'matrix';
6
6
 
7
- export type ResourceBrowserSource = {
7
+ export type AuthenticationConfiguration = {
8
+ authUrl: string;
9
+ redirectUrl: string;
10
+ clientId: string;
11
+ scope: string;
12
+ }
13
+
14
+ export interface ResourceBrowserSource {
8
15
  // Source name; shown on the UI
9
16
  name?: string;
10
17
  // Source identifier for additional information lookups
11
18
  id: string;
12
19
  // Source type e.g. Matrix, DAM etc determines what plugin will be used for resource selection
13
20
  type: ResourceBrowserPluginType;
14
- };
21
+ }
22
+
23
+ export interface ResourceBrowserSourceWithConfig extends ResourceBrowserSource {
24
+ configuration?: AuthenticationConfiguration;
25
+ }
15
26
 
16
27
  export type ResourceBrowserUnresolvedResource = {
17
28
  sourceId: string;
@@ -0,0 +1,88 @@
1
+ import { getCookieValue, setCookieValue, logout, refreshAccessToken } from './authUtils';
2
+ import { AuthenticationConfiguration } from '../types';
3
+
4
+ // Mock the global fetch function
5
+ global.fetch = jest.fn();
6
+
7
+ describe('auth-utils', () => {
8
+ beforeEach(() => {
9
+ // Clear all cookies before each test
10
+ document.cookie.split(';').forEach(cookie => {
11
+ const eqPos = cookie.indexOf('=');
12
+ const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
13
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
14
+ });
15
+ });
16
+
17
+ describe('getCookieValue', () => {
18
+ it('should return the value of the cookie if it exists', () => {
19
+ document.cookie = 'testCookie=testValue';
20
+ expect(getCookieValue('testCookie')).toBe('testValue');
21
+ });
22
+
23
+ it('should return null if the cookie does not exist', () => {
24
+ expect(getCookieValue('nonExistentCookie')).toBeNull();
25
+ });
26
+ });
27
+
28
+ describe('setCookieValue', () => {
29
+ it('should set the cookie with the given name and value', () => {
30
+ setCookieValue('testCookie', 'testValue');
31
+ expect(document.cookie).toContain('testCookie=testValue');
32
+ });
33
+ });
34
+
35
+ describe('logout', () => {
36
+ it('should clear authToken and refreshToken cookies', () => {
37
+ document.cookie = 'authToken=testAuthToken';
38
+ document.cookie = 'refreshToken=testRefreshToken';
39
+ logout();
40
+ expect(getCookieValue('authToken')).toBeNull();
41
+ expect(getCookieValue('refreshToken')).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe('refreshAccessToken', () => {
46
+ const authConfig: AuthenticationConfiguration = {
47
+ authUrl: 'https://auth.example.com',
48
+ clientId: 'example-client-id',
49
+ redirectUrl: 'https://example.com/callback',
50
+ scope: 'offline_access'
51
+ };
52
+
53
+ it('should throw an error if authConfig is not provided', async () => {
54
+ await expect(refreshAccessToken()).rejects.toThrow('No auth configuration available');
55
+ });
56
+
57
+ it('should throw an error if refreshToken is not available', async () => {
58
+ await expect(refreshAccessToken(authConfig)).rejects.toThrow('You are not logged in');
59
+ });
60
+
61
+ it('should refresh the access token and set the authToken cookie', async () => {
62
+ document.cookie = 'refreshToken=testRefreshToken';
63
+
64
+ // Mock fetch response
65
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
66
+ ok: true,
67
+ json: async () => ({ access_token: 'newAccessToken' }),
68
+ });
69
+
70
+ const newToken = await refreshAccessToken(authConfig);
71
+ expect(newToken).toBe('newAccessToken');
72
+ expect(getCookieValue('authToken')).toBe('newAccessToken');
73
+ });
74
+
75
+ it('should call logout and throw an error if the fetch response is not ok', async () => {
76
+ document.cookie = 'refreshToken=testRefreshToken';
77
+
78
+ // Mock fetch response
79
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
80
+ ok: false,
81
+ });
82
+
83
+ await expect(refreshAccessToken(authConfig)).rejects.toThrow('Failed to refresh token');
84
+ expect(getCookieValue('authToken')).toBeNull();
85
+ expect(getCookieValue('refreshToken')).toBeNull();
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,40 @@
1
+ import { AuthenticationConfiguration } from '../types';
2
+
3
+ export const getCookieValue = (name: string): string | null => {
4
+ const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
5
+ return match ? match.pop()! : null;
6
+ };
7
+
8
+ export const setCookieValue = (name: string, value: string): void => {
9
+ document.cookie = `${name}=${value}; Path=/;`;
10
+ };
11
+
12
+ export const logout = (): void => {
13
+ setCookieValue('authToken', '');
14
+ setCookieValue('refreshToken', '');
15
+ };
16
+
17
+ export const refreshAccessToken = async (authConfig?: AuthenticationConfiguration): Promise<string> => {
18
+ if (!authConfig) {
19
+ throw new Error('No auth configuration available');
20
+ }
21
+
22
+ const refreshToken = getCookieValue('refreshToken');
23
+ if (!refreshToken) {
24
+ throw new Error('You are not logged in');
25
+ }
26
+
27
+ const response = await fetch(`${authConfig.redirectUrl}?grant_type=refresh_token&refresh_token=${refreshToken}`, {
28
+ method: 'GET',
29
+ credentials: 'include',
30
+ });
31
+
32
+ if (!response.ok) {
33
+ logout();
34
+ throw new Error('Failed to refresh token');
35
+ }
36
+
37
+ const data = await response.json();
38
+ setCookieValue('authToken', data.access_token);
39
+ return data.access_token;
40
+ };