+
+ ,
+ );
+
+ 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 }) => (