@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 +6 -0
- package/lib/Hooks/useAuth.d.ts +7 -0
- package/lib/Hooks/useAuth.js +56 -0
- package/lib/Plugin/Plugin.js +3 -1
- package/lib/ResourceBrowserContext/AuthProvider.d.ts +16 -0
- package/lib/ResourceBrowserContext/AuthProvider.js +46 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +5 -1
- package/lib/types.d.ts +11 -2
- package/lib/utils/authUtils.d.ts +5 -0
- package/lib/utils/authUtils.js +38 -0
- package/package.json +2 -2
- package/src/Hooks/useAuth.spec.tsx +137 -0
- package/src/Hooks/useAuth.ts +60 -0
- package/src/Plugin/Plugin.tsx +2 -1
- package/src/ResourceBrowserContext/AuthProvider.spec.tsx +73 -0
- package/src/ResourceBrowserContext/AuthProvider.tsx +40 -0
- package/src/index.spec.tsx +3 -1
- package/src/index.tsx +3 -1
- package/src/types.ts +13 -2
- package/src/utils/authUtils.spec.ts +88 -0
- package/src/utils/authUtils.ts +40 -0
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;
|
package/lib/Plugin/Plugin.js
CHANGED
@@ -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(
|
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
|
-
|
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
|
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.
|
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": "
|
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
|
+
};
|
package/src/Plugin/Plugin.tsx
CHANGED
@@ -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
|
+
};
|
package/src/index.spec.tsx
CHANGED
@@ -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
|
-
|
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
|
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
|
+
};
|