diff --git a/.changeset/fair-socks-peel.md b/.changeset/fair-socks-peel.md new file mode 100644 index 0000000000..88aeb6517c --- /dev/null +++ b/.changeset/fair-socks-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-react': patch +--- + +Create a generic React component for refreshing user cookie. diff --git a/.changeset/serious-steaks-promise.md b/.changeset/serious-steaks-promise.md new file mode 100644 index 0000000000..55fee86f60 --- /dev/null +++ b/.changeset/serious-steaks-promise.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-addons-test-utils': patch +--- + +Use the new generic refresh user cookie provider. diff --git a/plugins/auth-react/.eslintrc.js b/plugins/auth-react/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-react/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-react/README.md b/plugins/auth-react/README.md new file mode 100644 index 0000000000..0137f95371 --- /dev/null +++ b/plugins/auth-react/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-auth-react + +Welcome to the web library package for the auth plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md new file mode 100644 index 0000000000..01ec9b028a --- /dev/null +++ b/plugins/auth-react/api-report.md @@ -0,0 +1,39 @@ +## API Report File for "@backstage/plugin-auth-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ReactNode } from 'react'; + +// @public +export function CookieAuthRefreshProvider( + props: CookieAuthRefreshProviderProps, +): JSX.Element; + +// @public +export type CookieAuthRefreshProviderProps = { + pluginId: string; + path?: string; + children: ReactNode; +}; + +// @public +export function useCookieAuthRefresh(options: { + pluginId: string; + path?: string; +}): + | { + status: 'loading'; + } + | { + status: 'error'; + error: Error; + retry: () => void; + } + | { + status: 'success'; + data: { + expiresAt: string; + }; + }; +``` diff --git a/plugins/auth-react/catalog-info.yaml b/plugins/auth-react/catalog-info.yaml new file mode 100644 index 0000000000..b75aa32c56 --- /dev/null +++ b/plugins/auth-react/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-react + title: '@backstage/plugin-auth-react' + description: Web library for the auth plugin +spec: + lifecycle: experimental + type: backstage-web-library + owner: maintainers diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json new file mode 100644 index 0000000000..875cb2f12f --- /dev/null +++ b/plugins/auth-react/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-auth-react", + "version": "0.0.0", + "description": "Web library for the auth plugin", + "backstage": { + "role": "web-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-react" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", + "@material-ui/core": "^4.9.13", + "@react-hookz/web": "^24.0.0", + "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.0.0" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + } +} diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx new file mode 100644 index 0000000000..786136261c --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -0,0 +1,170 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CookieAuthRefreshProvider } from './CookieAuthRefreshProvider'; +import { + MockStorageApi, + TestApiProvider, + renderInTestApp, +} from '@backstage/test-utils'; +import { + discoveryApiRef, + fetchApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; + +describe('CookieAuthRefreshProvider', () => { + const storageApiMock = MockStorageApi.create(); + const discoveryApiMock = { + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/techdocs/api'), + }; + + function getExpiresAtInFuture() { + const tenMinutesInMilliseconds = 10 * 60 * 1000; + return new Date(Date.now() + tenMinutesInMilliseconds).toISOString(); + } + + it('should render a progress bar', async () => { + const fetchApiMock = { + fetch: jest.fn().mockReturnValue(new Promise(() => {})), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + + expect(screen.getByTestId('progress')).toBeVisible(); + }); + + it('should render an error panel', async () => { + const error = new Error('Failed to get cookie'); + const fetchApiMock = { + fetch: jest.fn().mockRejectedValue(error), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + expect(screen.getByText(error.message)).toBeInTheDocument(); + }); + + it('should call the api again when retry is clicked', async () => { + const error = new Error('Failed to get cookie'); + const fetchApiMock = { + fetch: jest + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + expiresAt: getExpiresAtInFuture(), + }), + }), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + await waitFor(() => + expect(fetchApiMock.fetch).toHaveBeenCalledWith( + 'http://localhost:7000/techdocs/api/cookie', + { credentials: 'include' }, + ), + ); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + + expect(screen.getByText(error.message)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Retry')); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2); + + await waitFor(() => + expect(screen.getByText('Test Content')).toBeInTheDocument(), + ); + }); + + it('should render the children', async () => { + const fetchApiMock = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + expiresAt: getExpiresAtInFuture(), + }), + }), + }; + + await renderInTestApp( + + +
Test Content
+
+
, + ); + + await waitFor(() => + expect(screen.getByText('Test Content')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx new file mode 100644 index 0000000000..7919207692 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { ErrorPanel } from '@backstage/core-components'; +import { useApp } from '@backstage/core-plugin-api'; +import { Button } from '@material-ui/core'; +import { useCookieAuthRefresh } from '../../hooks'; + +/** + * @public + * Props for the {@link CookieAuthRefreshProvider} component. + */ +export type CookieAuthRefreshProviderProps = { + // The plugin ID used for discovering the API origin + pluginId: string; + // The path used for calling the refresh cookie endpoint, default to '/cookie' + path?: string; + // The children to render when the refresh is successful + children: ReactNode; +}; + +/** + * @public + * A provider that will refresh the cookie when it is about to expire. + */ +export function CookieAuthRefreshProvider( + props: CookieAuthRefreshProviderProps, +): JSX.Element { + const { children, ...options } = props; + const app = useApp(); + const { Progress } = app.getComponents(); + + const result = useCookieAuthRefresh(options); + + if (result.status === 'loading') { + return ; + } + + if (result.status === 'error') { + return ( + + + + ); + } + + return <>{children}; +} diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts b/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts new file mode 100644 index 0000000000..2bb172d850 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + CookieAuthRefreshProvider, + type CookieAuthRefreshProviderProps, +} from './CookieAuthRefreshProvider'; diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts new file mode 100644 index 0000000000..f561672096 --- /dev/null +++ b/plugins/auth-react/src/components/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The index file in ./components/ is typically responsible for selecting +// which components are public API and should be exported from the package. + +export * from './CookieAuthRefreshProvider'; diff --git a/plugins/auth-react/src/hooks/index.ts b/plugins/auth-react/src/hooks/index.ts new file mode 100644 index 0000000000..1257334498 --- /dev/null +++ b/plugins/auth-react/src/hooks/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The index file in ./hooks/ is typically responsible for selecting +// which hooks are public API and should be exported from the package. + +export * from './useCookieAuthRefresh'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts new file mode 100644 index 0000000000..42b60840f8 --- /dev/null +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { useCookieAuthRefresh } from './useCookieAuthRefresh'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx new file mode 100644 index 0000000000..180de98a46 --- /dev/null +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -0,0 +1,353 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { fetchApiRef, discoveryApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; +import { useCookieAuthRefresh } from './useCookieAuthRefresh'; + +describe('useCookieAuthRefresh', () => { + const discoveryApiMock = { + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/techdocs/api'), + }; + + const now = 1710316886171; + const tenMinutesInMilliseconds = 10 * 60 * 1000; + const tenMinutesFromNowInMilliseconds = now + tenMinutesInMilliseconds; + const expiresAt = new Date(tenMinutesFromNowInMilliseconds).toISOString(); + + const fetchApiMock = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ expiresAt }), + }), + }; + + beforeEach(() => { + jest.useFakeTimers({ now }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return a loading status when the refresh is in progress first time', () => { + const error = new Error('Failed to get cookie'); + + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.status).toBe('loading'); + }); + + it('should return a loading status when retrying without previous success', async () => { + const error = new Error('Failed to get cookie'); + + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + {})), + }, + ], + [discoveryApiRef, discoveryApiMock], + ]} + > + {children} + + ), + }, + ); + + expect(result.current).toStrictEqual({ status: 'loading' }); + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'error', + error, + retry: expect.any(Function), + }), + ); + + if (result.current.status === 'error') { + result.current.retry(); + } + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'loading', + }), + ); + }); + + it('should return a loading status when retrying with previous success', async () => { + const error = new Error('Failed to get cookie'); + + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + {})), + }, + ], + [discoveryApiRef, discoveryApiMock], + ]} + > + {children} + + ), + }, + ); + + expect(result.current).toStrictEqual({ status: 'loading' }); + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'success', + data: { expiresAt }, + }), + ); + + jest.advanceTimersByTime(tenMinutesInMilliseconds); + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'error', + error, + retry: expect.any(Function), + }), + ); + + if (result.current.status === 'error') { + result.current.retry(); + } + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'loading', + }), + ); + }); + + it('should return an error status when the refresh has failed', async () => { + const error = new Error('Failed to get cookie'); + + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'error', + error, + retry: expect.any(Function), + }), + ); + }); + + it('should call the api to get the cookie and use it', async () => { + const { result } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await waitFor(() => + expect(fetchApiMock.fetch).toHaveBeenCalledWith( + 'http://localhost:7000/techdocs/api/cookie', + { credentials: 'include' }, + ), + ); + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'success', + data: { expiresAt }, + }), + ); + }); + + it('should cancel the refresh when a message is received from another tab', async () => { + const pluginId = 'techdocs'; + + renderHook(() => useCookieAuthRefresh({ pluginId }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const twentyMinutesFromNowInMilliseconds = + now + 2 * tenMinutesInMilliseconds; + + // simulating other tab refreshing the cookie + new global.BroadcastChannel( + `${pluginId}-auth-cookie-expires-at`, + ).postMessage({ + action: 'COOKIE_REFRESH_SUCCESS', + payload: { + expiresAt: new Date(twentyMinutesFromNowInMilliseconds).toISOString(), + }, + }); + + // advance the timers in 10 minutes to match the old expires at + jest.advanceTimersByTime(tenMinutesInMilliseconds); + + // should not call the api + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); + + // advance the timers in more 10 minutes to match the new expires at + jest.advanceTimersByTime(tenMinutesInMilliseconds - 1000); + + // should call the api + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2)); + }); + + it('should cancel the refresh when the component is unmounted', async () => { + const { result, unmount } = renderHook( + () => useCookieAuthRefresh({ pluginId: 'techdocs' }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + await waitFor(() => + expect(result.current).toStrictEqual({ + status: 'success', + data: { expiresAt }, + }), + ); + + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); + + unmount(); + + // advance the timers to ensure that the refresh is not called + jest.advanceTimersByTime(tenMinutesInMilliseconds); + + // should not call the api after unmount + await waitFor(() => + expect(fetchApiMock.fetch).not.toHaveBeenCalledTimes(2), + ); + }); + + it('should refresh the cookie when it is about to expire', async () => { + renderHook(() => useCookieAuthRefresh({ pluginId: 'techdocs' }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1)); + + // advance the timers to the expiration date + jest.advanceTimersByTime(tenMinutesInMilliseconds - 1000); + + // should call the api + await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx new file mode 100644 index 0000000000..dfb13289db --- /dev/null +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useCallback, useMemo } from 'react'; +import { + discoveryApiRef, + fetchApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import { useAsync, useMountEffect } from '@react-hookz/web'; +import { ResponseError } from '@backstage/errors'; + +/** + * @public + * A hook that will refresh the cookie when it is about to expire. + * @param options - Options for configuring the refresh cookie endpoint + */ +export function useCookieAuthRefresh(options: { + // The plugin id used for discovering the API origin + pluginId: string; + // The path used for calling the refresh cookie endpoint, default to '/cookie' + path?: string; +}): + | { status: 'loading' } + | { status: 'error'; error: Error; retry: () => void } + | { status: 'success'; data: { expiresAt: string } } { + const { pluginId, path = '/cookie' } = options ?? {}; + const fetchApi = useApi(fetchApiRef); + const discoveryApi = useApi(discoveryApiRef); + + const channel = useMemo(() => { + return 'BroadcastChannel' in window + ? new BroadcastChannel(`${pluginId}-auth-cookie-expires-at`) + : null; + }, [pluginId]); + + const [state, actions] = useAsync<{ expiresAt: string }>(async () => { + const apiOrigin = await discoveryApi.getBaseUrl(pluginId); + const requestUrl = `${apiOrigin}${path}`; + const response = await fetchApi.fetch(`${requestUrl}`, { + credentials: 'include', + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + const data = await response.json(); + if (!data.expiresAt) { + throw new Error('No expiration date found in response'); + } + return data; + }); + + useMountEffect(actions.execute); + + const retry = useCallback(() => { + actions.execute(); + }, [actions]); + + const refresh = useCallback( + (params: { expiresAt: string }) => { + // Randomize the refreshing margin with a margin of 1-4 minutes to avoid all tabs refreshing at the same time + // It cannot be less than 5 minutes otherwise the backend will return the same expiration date + const margin = (1 + 3 * Math.random()) * 60000; + const delay = Date.parse(params.expiresAt) - Date.now() - margin; + const timeout = setTimeout(retry, delay); + return () => clearTimeout(timeout); + }, + [retry], + ); + + useEffect(() => { + // Only schedule a refresh if we have a successful response + if (state.status !== 'success' || !state.result) { + return () => {}; + } + channel?.postMessage({ + action: 'COOKIE_REFRESH_SUCCESS', + payload: state.result, + }); + let cancel = refresh(state.result); + const listener = ( + event: MessageEvent<{ action: string; payload: { expiresAt: string } }>, + ) => { + const { action, payload } = event.data; + if (action === 'COOKIE_REFRESH_SUCCESS') { + cancel(); + cancel = refresh(payload); + } + }; + channel?.addEventListener('message', listener); + return () => { + cancel(); + channel?.removeEventListener('message', listener); + }; + }, [state, refresh, channel]); + + // Initialising + if (state.status === 'not-executed') { + return { status: 'loading' }; + } + + // First refresh or retrying without any success before + // Possible state transitions: + // e.g. not-executed -> loading (first-refresh) + // e.g. not-executed -> loading (first-refresh) -> error -> loading (manual-retry) + if (state.status === 'loading' && !state.result) { + return { status: 'loading' }; + } + + // Retrying after having succeeding at least once + // Current state is: { status: 'loading', result: {...}, error: undefined | Error } + // e.g. not-executed -> loading (first-refresh) -> success -> loading (scheduled-refresh) -> error -> loading (manual-retry) + if (state.status === 'loading' && state.error) { + return { status: 'loading' }; + } + + // Something went wrong during any situation of a refresh + if (state.status === 'error' && state.error) { + return { status: 'error', error: state.error, retry }; + } + + // At this point it should be safe to assume that we have a successful refresh + return { status: 'success', data: state.result! }; +} diff --git a/plugins/auth-react/src/index.ts b/plugins/auth-react/src/index.ts new file mode 100644 index 0000000000..30ac7cfe72 --- /dev/null +++ b/plugins/auth-react/src/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Web library for the auth plugin. + * + * @packageDocumentation + */ + +// In this package you might for example export components or hooks +// that are useful to other plugins or modules. + +export * from './components'; +export * from './hooks'; diff --git a/plugins/auth-react/src/setupTests.ts b/plugins/auth-react/src/setupTests.ts new file mode 100644 index 0000000000..6580ddffad --- /dev/null +++ b/plugins/auth-react/src/setupTests.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; + +global.BroadcastChannel = jest + .fn() + .mockImplementation((_channelName: string) => { + const listeners: ((event: { data: any }) => void)[] = []; + + return { + addEventListener: ( + type: string, + listener: (event: { data: any }) => void, + ) => { + if (type === 'message') { + listeners.push(listener); + } + }, + removeEventListener: ( + type: string, + listener: (event: { data: any }) => void, + ) => { + if (type === 'message') { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + } + }, + postMessage: (message: any) => { + listeners.forEach(listener => { + listener({ data: message }); + }); + }, + }; + }); diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index f26dfea6fd..51a4650f99 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -24,7 +24,11 @@ import { act, render } from '@testing-library/react'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FlatRoutes } from '@backstage/core-app-api'; -import { ApiRef } from '@backstage/core-plugin-api'; +import { + ApiRef, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; import { TechDocsAddons, @@ -69,6 +73,22 @@ const scmIntegrationsApi = { fromConfig: jest.fn().mockReturnValue({}), }; +const discoveryApi = { + getBaseUrl: jest + .fn() + .mockResolvedValue('https://backstage.example.com/api/techdocs'), +}; + +const fetchApi = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }), + }), +}; + /** @ignore */ type TechDocsAddonTesterTestApiPair = TApi extends infer TImpl ? readonly [ApiRef, Partial] @@ -204,6 +224,8 @@ export class TechDocsAddonTester { */ build() { const apis: TechdocsAddonTesterApis = [ + [fetchApiRef, fetchApi], + [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 533736c3bc..a51eb5f723 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -60,6 +60,7 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/integration-react": "workspace:^", + "@backstage/plugin-auth-react": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx deleted file mode 100644 index baeb809a6c..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { ReactNode, useEffect, useState, useCallback } from 'react'; -import { ErrorPanel } from '@backstage/core-components'; -import { techdocsApiRef } from '@backstage/plugin-techdocs-react'; -import { useApi, useApp } from '@backstage/core-plugin-api'; -import useAsyncRetry from 'react-use/lib/useAsyncRetry'; -import Button from '@material-ui/core/Button'; - -type TechDocsRefreshCookieMessage = MessageEvent<{ - action: string; - payload: { - expiresAt: string; - }; -}>; - -function useTechDocsCookie() { - const techdocsApi = useApi(techdocsApiRef); - - const { retry, ...state } = useAsyncRetry(async () => { - return await techdocsApi.getCookie(); - }, [techdocsApi]); - - const refresh = useCallback( - (expiresAt: string) => { - // Randomize the refreshing margin to avoid all tabs refreshing at the same time - const refreshingMargin = (1 + 3 * Math.random()) * 60000; - const delay = Date.parse(expiresAt) - Date.now() - refreshingMargin; - const timeout = setTimeout(retry, delay); - return () => clearTimeout(timeout); - }, - [retry], - ); - - return { ...state, retry, refresh }; -} - -export function TechDocsAuthProvider({ children }: { children: ReactNode }) { - const app = useApp(); - const { Progress } = app.getComponents(); - - const [channel] = useState( - () => new BroadcastChannel('techdocs-cookie-refresh'), - ); - - const { loading, error, value, retry, refresh } = useTechDocsCookie(); - - useEffect(() => { - if (!value) return () => {}; - - channel.postMessage({ - action: 'TECHDOCS_COOKIE_REFRESHED', - payload: value, - }); - - let cancel = refresh(value.expiresAt); - - const handleMessage = (event: TechDocsRefreshCookieMessage): void => { - const { action, payload } = event.data; - if (action === 'TECHDOCS_COOKIE_REFRESHED') { - cancel(); - cancel = refresh(payload.expiresAt); - } - }; - - channel.addEventListener('message', handleMessage); - - return () => { - cancel(); - channel.removeEventListener('message', handleMessage); - }; - }, [value, refresh, channel]); - - if (error) { - return ( - - - - ); - } - - if (loading) { - return ; - } - - return children; -} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 605decae12..ed8b05c661 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -34,7 +34,11 @@ import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; import { FlatRoutes } from '@backstage/core-app-api'; import { Page } from '@backstage/core-components'; -import { configApiRef } from '@backstage/core-plugin-api'; +import { + configApiRef, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; const mockEntityMetadata = { locationMetadata: { @@ -76,6 +80,22 @@ const techdocsStorageApiMock: jest.Mocked = { syncEntityDocs: jest.fn(), }; +const discoveryApiMock = { + getBaseUrl: jest + .fn() + .mockResolvedValue('https://localhost:7000/api/techdocs'), +}; + +const fetchApiMock = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }), + }), +}; + const PageMock = () => { const { namespace, kind, name } = useParams(); return <>{`PageMock: ${namespace}#${kind}#${name}`}; @@ -96,6 +116,8 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( ', () => { beforeEach(() => { - type Listener = (event: { data: any }) => void; - - global.BroadcastChannel = jest - .fn() - .mockImplementation((_channelName: string) => { - let listeners: Listener[] = []; - return { - postMessage: jest.fn((message: any) => { - listeners.forEach(listener => listener({ data: message })); - }), - addEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners.push(listener); - } - }), - removeEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners = listeners.filter(l => l !== listener); - } - }), - }; - }); - getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); getCookie.mockResolvedValue({ @@ -147,7 +146,7 @@ describe('', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); beforeEach(() => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index d7f3843ebb..b1b9ecb13b 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -35,7 +35,8 @@ import { getComponentData, useRouteRefParams, } from '@backstage/core-plugin-api'; -import { TechDocsAuthProvider } from './TechDocsAuthProvider'; + +import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -178,17 +179,17 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { // As explained above, "page" is configuration 4 and is 1 return ( - + {(page as JSX.Element) || } - + ); } // As explained above, a render function is configuration 3 and React element is 2 return ( - + {({ metadata, entityMetadata, onReady }) => (
@@ -205,6 +206,6 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
)}
-
+ ); }; diff --git a/plugins/techdocs/src/setupTests.ts b/plugins/techdocs/src/setupTests.ts index 0bdcc73ccb..6c7fc2d3e3 100644 --- a/plugins/techdocs/src/setupTests.ts +++ b/plugins/techdocs/src/setupTests.ts @@ -17,28 +17,3 @@ import '@testing-library/jest-dom'; Element.prototype.scrollIntoView = jest.fn(); - -type Listener = (event: { data: any }) => void; - -global.BroadcastChannel = jest - .fn() - .mockImplementation((_channelName: string) => { - let listeners: Listener[] = []; - return { - postMessage: jest.fn((message: any) => { - // Simulate message event for all listeners - listeners.forEach(listener => listener({ data: message })); - }), - addEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners.push(listener); - } - }), - removeEventListener: jest.fn((event: string, listener: Listener) => { - if (event === 'message') { - listeners = listeners.filter(l => l !== listener); - } - }), - close: jest.fn(), - }; - }); diff --git a/yarn.lock b/yarn.lock index 73b18a599a..56f321fd5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5110,6 +5110,26 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-react@workspace:^, @backstage/plugin-auth-react@workspace:plugins/auth-react": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-react@workspace:plugins/auth-react" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@material-ui/core": ^4.9.13 + "@react-hookz/web": ^24.0.0 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 + "@testing-library/user-event": ^14.0.0 + "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-azure-devops-backend@workspace:^, @backstage/plugin-azure-devops-backend@workspace:plugins/azure-devops-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-azure-devops-backend@workspace:plugins/azure-devops-backend" @@ -9868,6 +9888,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" + "@backstage/plugin-auth-react": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^"