@squiz/resource-browser 2.1.10-rc.0 → 2.2.1-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,16 @@
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.1-rc.0](https://gitlab.squiz.net/dxp/dxp-shared-ui/resource-browser/compare/@squiz/resource-browser@2.2.0-rc.0...@squiz/resource-browser@2.2.1-rc.0) (2024-06-07)
7
+
8
+ **Note:** Version bump only for package @squiz/resource-browser
9
+
10
+ # [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)
11
+
12
+ ### Features
13
+
14
+ - **PRODAM:92:** auth provider ([3c7136d](https://gitlab.squiz.net/dxp/dxp-shared-ui/resource-browser/commit/3c7136da5f5ef148f713ad6a39f5d2328eaff4c7))
15
+
6
16
  ## [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
17
 
8
18
  **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;
@@ -40,22 +40,23 @@ function MainContainer({ title, titleAriaProps, allowedTypes, sources, selectedS
40
40
  }, [setHeaderPortal]);
41
41
  // MainContainer will either render the source list view if no source is set or the plugins UI if a source has been selected
42
42
  return (react_1.default.createElement("div", { className: "relative flex flex-col h-full text-gray-800" },
43
- react_1.default.createElement("div", { className: "flex items-center p-4.5" },
43
+ react_1.default.createElement("div", { className: "flex items-center py-4.5 pl-4.5 pr-10" },
44
44
  react_1.default.createElement("h2", { ...titleAriaProps, className: "text-xl leading-6 text-gray-800 font-semibold mr-6" },
45
45
  !plugin && 'Environment Selector',
46
46
  plugin && title),
47
47
  plugin && selectedSource && (react_1.default.createElement(react_1.default.Fragment, null,
48
48
  sources.length > 1 && (react_1.default.createElement("div", { className: "px-3 border-l border-gray-300 w-300px" },
49
49
  react_1.default.createElement(SourceDropdown_1.default, { sources: sources, selectedSource: selectedSource, onSourceSelect: onSourceSelect }))),
50
- plugin.createHeaderPortal && (react_1.default.createElement("div", { ref: setHeaderPortalRef, className: "px-3 border-l border-gray-300 w-300px" })))),
50
+ plugin.createHeaderPortal && (react_1.default.createElement("div", { ref: setHeaderPortalRef, className: "squiz-rb-plugin px-3 border-l border-gray-300 w-300px" })))),
51
51
  react_1.default.createElement("button", { type: "button", "aria-label": `Close ${title} dialog`, onClick: onClose, className: "absolute top-2 right-2 p-2.5 rounded hover:bg-blue-100 focus:bg-blue-100" },
52
52
  react_1.default.createElement("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", xmlns: "http://www.w3.org/2000/svg" },
53
53
  react_1.default.createElement("path", { d: "M13.3 0.710017C13.1131 0.522765 12.8595 0.417532 12.595 0.417532C12.3305 0.417532 12.0768 0.522765 11.89 0.710017L6.99997 5.59002L2.10997 0.700017C1.92314 0.512765 1.66949 0.407532 1.40497 0.407532C1.14045 0.407532 0.886802 0.512765 0.699971 0.700017C0.309971 1.09002 0.309971 1.72002 0.699971 2.11002L5.58997 7.00002L0.699971 11.89C0.309971 12.28 0.309971 12.91 0.699971 13.3C1.08997 13.69 1.71997 13.69 2.10997 13.3L6.99997 8.41002L11.89 13.3C12.28 13.69 12.91 13.69 13.3 13.3C13.69 12.91 13.69 12.28 13.3 11.89L8.40997 7.00002L13.3 2.11002C13.68 1.73002 13.68 1.09002 13.3 0.710017Z", fill: "currentColor" })))),
54
- react_1.default.createElement("div", { className: "squiz-rb-plugin border-t border-gray-300 overflow-y-hidden" },
55
- plugin && selectedSource && SourceBrowser && (react_1.default.createElement(SourceBrowser, { source: selectedSource, allowedTypes: allowedTypes, headerPortal: plugin.createHeaderPortal && headerPortal ? headerPortal : undefined, preselectedResource: preselectedResource || undefined, onSelected: (resource) => {
56
- onChange(resource);
57
- onClose();
58
- } })),
54
+ react_1.default.createElement("div", { className: "border-t border-gray-300 overflow-y-hidden" },
55
+ plugin && selectedSource && SourceBrowser && (react_1.default.createElement("div", { className: "squiz-rb-plugin" },
56
+ react_1.default.createElement(SourceBrowser, { source: selectedSource, allowedTypes: allowedTypes, headerPortal: plugin.createHeaderPortal && headerPortal ? headerPortal : undefined, preselectedResource: preselectedResource || undefined, onSelected: (resource) => {
57
+ onChange(resource);
58
+ onClose();
59
+ } }))),
59
60
  !selectedSource && react_1.default.createElement(SourceList_1.default, { sources: sources, onSourceSelect: onSourceSelect }))));
60
61
  }
61
62
  exports.default = MainContainer;
@@ -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.css CHANGED
@@ -610,6 +610,9 @@
610
610
  .squiz-rb-scope .max-w-\[50rem\]:not(.squiz-rb-plugin *) {
611
611
  max-width: 50rem;
612
612
  }
613
+ .squiz-rb-scope .max-w-\[52rem\]:not(.squiz-rb-plugin *) {
614
+ max-width: 52rem;
615
+ }
613
616
  .squiz-rb-scope .flex-1:not(.squiz-rb-plugin *) {
614
617
  flex: 1 1 0%;
615
618
  }
@@ -815,6 +818,14 @@
815
818
  padding-top: 0.5rem;
816
819
  padding-bottom: 0.5rem;
817
820
  }
821
+ .squiz-rb-scope .py-4:not(.squiz-rb-plugin *) {
822
+ padding-top: 1rem;
823
+ padding-bottom: 1rem;
824
+ }
825
+ .squiz-rb-scope .py-4\.5:not(.squiz-rb-plugin *) {
826
+ padding-top: 1.125rem;
827
+ padding-bottom: 1.125rem;
828
+ }
818
829
  .squiz-rb-scope .py-8:not(.squiz-rb-plugin *) {
819
830
  padding-top: 2rem;
820
831
  padding-bottom: 2rem;
@@ -825,12 +836,24 @@
825
836
  .squiz-rb-scope .pb-4:not(.squiz-rb-plugin *) {
826
837
  padding-bottom: 1rem;
827
838
  }
839
+ .squiz-rb-scope .pb-4\.5:not(.squiz-rb-plugin *) {
840
+ padding-bottom: 1.125rem;
841
+ }
828
842
  .squiz-rb-scope .pl-4:not(.squiz-rb-plugin *) {
829
843
  padding-left: 1rem;
830
844
  }
845
+ .squiz-rb-scope .pl-4\.5:not(.squiz-rb-plugin *) {
846
+ padding-left: 1.125rem;
847
+ }
848
+ .squiz-rb-scope .pr-10:not(.squiz-rb-plugin *) {
849
+ padding-right: 2.5rem;
850
+ }
831
851
  .squiz-rb-scope .pr-4:not(.squiz-rb-plugin *) {
832
852
  padding-right: 1rem;
833
853
  }
854
+ .squiz-rb-scope .pr-4\.5:not(.squiz-rb-plugin *) {
855
+ padding-right: 1.125rem;
856
+ }
834
857
  .squiz-rb-scope .pt-3:not(.squiz-rb-plugin *) {
835
858
  padding-top: 0.75rem;
836
859
  }
@@ -1032,18 +1055,6 @@
1032
1055
  --tw-text-opacity: 1;
1033
1056
  color: rgb(112 112 112 / var(--tw-text-opacity));
1034
1057
  }
1035
- .squiz-rb-scope .p-4\.5:not(.squiz-rb-plugin *) {
1036
- padding: 18px;
1037
- }
1038
- .squiz-rb-scope .pl-4\.5:not(.squiz-rb-plugin *) {
1039
- padding-left: 18px;
1040
- }
1041
- .squiz-rb-scope .pr-4\.5:not(.squiz-rb-plugin *) {
1042
- padding-right: 18px;
1043
- }
1044
- .squiz-rb-scope .pb-4\.5:not(.squiz-rb-plugin *) {
1045
- padding-bottom: 18px;
1046
- }
1047
1058
  .squiz-rb-scope .break-word:not(.squiz-rb-plugin *) {
1048
1059
  word-break: break-word;
1049
1060
  overflow-wrap: anywhere;
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.1-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": "2ae6e09c8e222b2ef95f12243abe659ed76f0fd0"
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
+ };
@@ -46,7 +46,7 @@ function MainContainer({
46
46
  // MainContainer will either render the source list view if no source is set or the plugins UI if a source has been selected
47
47
  return (
48
48
  <div className="relative flex flex-col h-full text-gray-800">
49
- <div className="flex items-center p-4.5">
49
+ <div className="flex items-center py-4.5 pl-4.5 pr-10">
50
50
  <h2 {...titleAriaProps} className="text-xl leading-6 text-gray-800 font-semibold mr-6">
51
51
  {!plugin && 'Environment Selector'}
52
52
  {plugin && title}
@@ -60,7 +60,7 @@ function MainContainer({
60
60
  </div>
61
61
  )}
62
62
  {plugin.createHeaderPortal && (
63
- <div ref={setHeaderPortalRef} className="px-3 border-l border-gray-300 w-300px"></div>
63
+ <div ref={setHeaderPortalRef} className="squiz-rb-plugin px-3 border-l border-gray-300 w-300px"></div>
64
64
  )}
65
65
  </>
66
66
  )}
@@ -79,18 +79,20 @@ function MainContainer({
79
79
  </svg>
80
80
  </button>
81
81
  </div>
82
- <div className="squiz-rb-plugin border-t border-gray-300 overflow-y-hidden">
82
+ <div className="border-t border-gray-300 overflow-y-hidden">
83
83
  {plugin && selectedSource && SourceBrowser && (
84
- <SourceBrowser
85
- source={selectedSource}
86
- allowedTypes={allowedTypes}
87
- headerPortal={plugin.createHeaderPortal && headerPortal ? headerPortal : undefined}
88
- preselectedResource={preselectedResource || undefined}
89
- onSelected={(resource: ResourceBrowserResource) => {
90
- onChange(resource);
91
- onClose();
92
- }}
93
- />
84
+ <div className="squiz-rb-plugin">
85
+ <SourceBrowser
86
+ source={selectedSource}
87
+ allowedTypes={allowedTypes}
88
+ headerPortal={plugin.createHeaderPortal && headerPortal ? headerPortal : undefined}
89
+ preselectedResource={preselectedResource || undefined}
90
+ onSelected={(resource: ResourceBrowserResource) => {
91
+ onChange(resource);
92
+ onClose();
93
+ }}
94
+ />
95
+ </div>
94
96
  )}
95
97
  {!selectedSource && <SourceList sources={sources} onSourceSelect={onSourceSelect} />}
96
98
  </div>
@@ -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
+ };
@@ -21,7 +21,7 @@ export const createPlugins = (callbackWait: number, headerPortal = false): Resou
21
21
  sourceBrowserComponent: () => {
22
22
  return (props) => {
23
23
  return (
24
- <div className="h-screen lg:h-[calc(100vh-9rem)] w-screen max-w-[50rem]">
24
+ <div className="h-screen lg:h-[calc(100vh-9rem)] w-screen max-w-[52rem]">
25
25
  <div>THIS IS A {type} PLUGIN</div>
26
26
  <button
27
27
  onClick={() => {
package/src/index.scss CHANGED
@@ -18,22 +18,6 @@ svg {
18
18
  @apply text-gray-600;
19
19
  }
20
20
 
21
- .p-4\.5 {
22
- padding: 18px;
23
- }
24
-
25
- .pl-4\.5 {
26
- padding-left: 18px;
27
- }
28
-
29
- .pr-4\.5 {
30
- padding-right: 18px;
31
- }
32
-
33
- .pb-4\.5 {
34
- padding-bottom: 18px;
35
- }
36
-
37
21
  // In tailwind there is no break-word as it is deprecated, but break-words which is slightly different does not work here, so I have added the suggested combination here
38
22
  // https://v1.tailwindcss.com/docs/word-break
39
23
  // https://github.com/tailwindlabs/tailwindcss/discussions/2213
@@ -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
+ };
@@ -63,6 +63,7 @@ module.exports = {
63
63
  2: '0.5rem', // 8px
64
64
  3: '0.75rem', // 12px
65
65
  4: '1rem', // 16px
66
+ 4.5: '1.125rem', // 18px
66
67
  5: '1.25rem', // 20px
67
68
  6: '1.5rem', // 24px
68
69
  7: '1.75rem', // 28px