Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .storybook/decorators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import {fn} from 'storybook/test';
import {BASE_URL, mswWorker} from '@/api-mocks';
import FormLayout from '@/components/layout/FormLayout';
import AdminSettingsProvider from '@/context/AdminSettingsProvider';
import type {AdminSettings} from '@/context/context';
import RouterErrorBoundary from '@/errors/RouterErrorBoundary';
import {sessionExpiresAt} from '@/guard/session/session-expiry';
import {formLoader} from '@/queryClient';

export const withAdminSettingsProvider: Decorator = (Story, {parameters}) => (
<AdminSettingsProvider
apiBaseUrl={parameters?.adminSettings?.apiBaseUrl ?? BASE_URL}
djangoUrls={
parameters?.adminSettings?.djangoUrls ??
({
generalConfiguration: 'http://localhost:8000/admin/config/globalconfiguration/',
adminLogin: 'http://localhost:8000/admin/classic-login/',
} satisfies AdminSettings['djangoUrls'])
}
environmentInfo={{
label: parameters?.adminSettings?.environmentInfo?.label ?? 'storybook-test',
showBadge: parameters?.adminSettings?.environmentInfo?.showBadge ?? true,
Expand Down
6 changes: 6 additions & 0 deletions i18n/compiled/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
"value": "Your session has expired."
}
],
"OhINq5": [
{
"type": 0,
"value": "View general configuration"
}
],
"Qroj81": [
{
"type": 0,
Expand Down
6 changes: 6 additions & 0 deletions i18n/compiled/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
"value": "Je sessie is verlopen."
}
],
"OhINq5": [
{
"type": 0,
"value": "Bekijk algemene instellingen"
}
],
"Qroj81": [
{
"type": 0,
Expand Down
5 changes: 5 additions & 0 deletions i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
"description": "Session expiry notice - expired",
"originalDefault": "Your session has expired."
},
"OhINq5": {
"defaultMessage": "View general configuration",
"description": "General configuration button text",
"originalDefault": "View general configuration"
},
"Qroj81": {
"defaultMessage": "Forms",
"description": "Route breadcrumb label for forms overview",
Expand Down
5 changes: 5 additions & 0 deletions i18n/messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
"description": "Session expiry notice - expired",
"originalDefault": "Your session has expired."
},
"OhINq5": {
"defaultMessage": "Bekijk algemene instellingen",
"description": "General configuration button text",
"originalDefault": "View general configuration"
},
"Qroj81": {
"defaultMessage": "Formulieren",
"description": "Route breadcrumb label for forms overview",
Expand Down
15 changes: 13 additions & 2 deletions src/AdminUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,30 @@ interface AdminUIProps {
* Configuration for the Open Forms API base URL.
*/
apiBaseUrl: AdminSettings['apiBaseUrl'];
/**
* The configuration of the Open Forms Django URLs.
*
* These urls should be constructed using the python `reverse` function to ensure
* that the urls are correct for the current environment.
*/
djangoUrls: AdminSettings['djangoUrls'];
}

/**
* Main component to render the Open Forms Admin UI.
*/
const AdminUI: React.FC<AdminUIProps> = ({environmentInfo, apiBaseUrl}) => {
const AdminUI: React.FC<AdminUIProps> = ({environmentInfo, apiBaseUrl, djangoUrls}) => {
const router = createBrowserRouter(routes, {
basename: '/admin-ui',
});

return (
<React.StrictMode>
<AdminSettingsProvider environmentInfo={environmentInfo} apiBaseUrl={apiBaseUrl}>
<AdminSettingsProvider
environmentInfo={environmentInfo}
apiBaseUrl={apiBaseUrl}
djangoUrls={djangoUrls}
>
<RouterProvider router={router} />
</AdminSettingsProvider>
</React.StrictMode>
Expand Down
13 changes: 13 additions & 0 deletions src/components/button/GeneralConfigurationButton.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Canvas, Meta} from '@storybook/addon-docs/blocks';

import * as GeneralConfigurationButtonStories from './GeneralConfigurationButton.stories';

<Meta of={GeneralConfigurationButtonStories} />

# General Configuration Button

The `GeneralConfigurationButton` component can be used to navigate to the general configuration page
of the Open Forms admin. It implements a regular HTML Anchor element and uses the `AdminSettings`
defined `djangoUrls.generalConfiguration` as the target URL.

<Canvas of={GeneralConfigurationButtonStories.Default} />
35 changes: 35 additions & 0 deletions src/components/button/GeneralConfigurationButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type {Meta, StoryObj} from '@storybook/react-vite';
import {expect, within} from 'storybook/test';

import GeneralConfigurationButton from './GeneralConfigurationButton';

export default {
title: 'Internal API / Button / General Configuration Button',
component: GeneralConfigurationButton,
} satisfies Meta<typeof GeneralConfigurationButton>;

type Story = StoryObj<typeof GeneralConfigurationButton>;

export const Default: Story = {
parameters: {
adminSettings: {
djangoUrls: {
generalConfiguration: 'http://localhost:8000/admin/config/globalconfiguration/',
},
},
},
play: ({canvasElement}) => {
const canvas = within(canvasElement);

const generalConfigurationButton = canvas.getByRole('link', {
name: 'View general configuration',
});

// Expect the button to be shown and it uses djangoUrls.generalConfiguration url
expect(generalConfigurationButton).toBeVisible();
expect(generalConfigurationButton).toHaveAttribute(
'href',
'http://localhost:8000/admin/config/globalconfiguration/'
);
},
};
20 changes: 20 additions & 0 deletions src/components/button/GeneralConfigurationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {ButtonLink, Outline} from '@maykin-ui/admin-ui';
import {FormattedMessage} from 'react-intl';

import {useAdminSettings} from '@/hooks/useAdminSettings';

const GeneralConfigurationButton = () => {
const {djangoUrls} = useAdminSettings();

return (
<ButtonLink variant="secondary" href={djangoUrls.generalConfiguration}>
<FormattedMessage
description="General configuration button text"
defaultMessage="View general configuration"
/>
<Outline.ArrowRightIcon />
</ButtonLink>
);
};

export default GeneralConfigurationButton;
1 change: 1 addition & 0 deletions src/components/button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {default as GeneralConfigurationButton} from './GeneralConfigurationButton';
3 changes: 2 additions & 1 deletion src/context/AdminSettingsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import type {AdminSettings} from './context';

const AdminSettingsProvider: React.FC<React.PropsWithChildren<AdminSettings>> = ({
apiBaseUrl,
djangoUrls,
environmentInfo,
children,
}) => (
<AdminSettingsContext.Provider value={{environmentInfo, apiBaseUrl}}>
<AdminSettingsContext.Provider value={{environmentInfo, djangoUrls, apiBaseUrl}}>
{children}
</AdminSettingsContext.Provider>
);
Expand Down
8 changes: 8 additions & 0 deletions src/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@ export interface AdminSettings {
* The base URL of the Open Forms API.
*/
apiBaseUrl: string;
/**
* The configuration of the Open Forms Django URLs.
*/
djangoUrls: {
generalConfiguration: string;
adminLogin: string;
};
}

const AdminSettingsContext = React.createContext<AdminSettings>({
environmentInfo: {label: '', showBadge: true},
apiBaseUrl: '',
djangoUrls: {generalConfiguration: '', adminLogin: ''},
});

AdminSettingsContext.displayName = 'AdminSettingsContext';
Expand Down
8 changes: 6 additions & 2 deletions src/errors/NotAuthenticatedError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {A, Banner, Body} from '@maykin-ui/admin-ui';
import {FormattedMessage, useIntl} from 'react-intl';
import {useLocation} from 'react-router';

import {useAdminSettings} from '@/hooks/useAdminSettings';
import {getLoginUrl} from '@/utils/url';

import ErrorMessage from './ErrorMessage';
import type {NotAuthenticatedException} from './exceptions';

Expand All @@ -12,7 +15,8 @@ interface NotAuthenticatedErrorProps {
const NotAuthenticatedError: React.FC<NotAuthenticatedErrorProps> = ({error}) => {
const intl = useIntl();
const location = useLocation();
const params = new URLSearchParams({next: location.pathname});
const {djangoUrls} = useAdminSettings();
const adminLoginUrl = getLoginUrl(djangoUrls, location.pathname);

return (
<>
Expand All @@ -31,7 +35,7 @@ const NotAuthenticatedError: React.FC<NotAuthenticatedErrorProps> = ({error}) =>
<ErrorMessage error={error} />

<Body>
<A href={`/admin/login/?${params}`}>
<A href={adminLoginUrl}>
<FormattedMessage
description="'Go to login page' link text"
defaultMessage="Go to the login page"
Expand Down
14 changes: 12 additions & 2 deletions src/guard/AuthenticationRequired.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ import {sessionExpiresAt} from './session/session-expiry';
vi.spyOn(redirect, 'toLogin').mockImplementation(vi.fn());

const Wrapper: React.FC<React.PropsWithChildren> = ({children}) => (
<AdminSettingsProvider apiBaseUrl={BASE_URL} environmentInfo={{label: 'of-dev', showBadge: true}}>
<AdminSettingsProvider
apiBaseUrl={BASE_URL}
djangoUrls={{
generalConfiguration: 'http://localhost:8000/admin/config/globalconfiguration/',
adminLogin: 'http://localhost:8000/admin/classic-login/',
}}
environmentInfo={{label: 'of-dev', showBadge: true}}
>
<IntlProvider locale="en">{children}</IntlProvider>
</AdminSettingsProvider>
);
Expand Down Expand Up @@ -212,7 +219,10 @@ test('User is not authenticated in application and not on server', async () => {
// Expect the redirect.toLogin function and the mock api to have been called
await waitFor(() => {
// The `toLogin` function should be called with the expected redirect path.
expect(redirect.toLogin).toHaveBeenCalledWith('/required-auth');
expect(redirect.toLogin).toHaveBeenCalledWith(
{adminLogin: expect.anything(), generalConfiguration: expect.anything()},
'/required-auth'
);

expect(spy).toHaveBeenCalled();
});
Expand Down
6 changes: 3 additions & 3 deletions src/guard/AuthenticationRequired.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import SessionStatus from './session/SessionStatus';
import {sessionExpiresAt} from './session/session-expiry';

const AuthenticationRequired: React.FC<React.PropsWithChildren> = ({children}) => {
const {apiBaseUrl} = useAdminSettings();
const {apiBaseUrl, djangoUrls} = useAdminSettings();
const location = useLocation();
const [sessionExpiry] = sessionExpiresAt.useState();
const {date, authFailure} = sessionExpiry;
Expand All @@ -21,11 +21,11 @@ const AuthenticationRequired: React.FC<React.PropsWithChildren> = ({children}) =
apiCall(`${apiBaseUrl}accounts/me`).then(response => {
// If the user is not authenticated, redirect to the login page.
if (response.status === 401) {
redirect.toLogin(location.pathname);
redirect.toLogin(djangoUrls, location.pathname);
}
});
}
}, [apiBaseUrl, authFailure, date, location.pathname]);
}, [apiBaseUrl, authFailure, date, djangoUrls, location.pathname]);

return <SessionStatus>{children}</SessionStatus>;
};
Expand Down
9 changes: 8 additions & 1 deletion src/queryClient/form.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ import {formLoader, getFormDetailsQueryKey, useFormMutation} from '@/queryClient
import type {Form} from '@/types/form';

const AppWrapper: React.FC<React.PropsWithChildren> = ({children}) => (
<AdminSettingsProvider apiBaseUrl={BASE_URL} environmentInfo={{label: 'of-dev', showBadge: true}}>
<AdminSettingsProvider
apiBaseUrl={BASE_URL}
djangoUrls={{
generalConfiguration: 'http://localhost:8000/admin/config/globalconfiguration/',
adminLogin: 'http://localhost:8000/admin/classic-login/',
}}
environmentInfo={{label: 'of-dev', showBadge: true}}
>
<IntlProvider locale="en">{children}</IntlProvider>
</AdminSettingsProvider>
);
Expand Down
12 changes: 5 additions & 7 deletions src/utils/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// We wrap redirect functions inside an object because ES module named exports are
// immutable and cannot be spied on in Vitest. Spying on object methods is supported
// since they are configurable.
// See: https://vitest.dev/guide/mocking.html#mocking-modules
import type {AdminSettings} from '@/context/context';
import {getLoginUrl} from '@/utils/url';

export const redirect = {
// Redirect to the default `/admin/login` screen
toLogin: (nextUrl: string) => {
const params = new URLSearchParams({next: nextUrl});
window.location.assign(`${window.location.origin}/admin/login/?${params}`);
toLogin: (djangoUrls: AdminSettings['djangoUrls'], nextUrl: string) => {
window.location.assign(getLoginUrl(djangoUrls, nextUrl));
},
};
6 changes: 6 additions & 0 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type {AdminSettings} from '@/context/context';

export const getLoginUrl = (djangoUrls: AdminSettings['djangoUrls'], nextUrl: string) => {
const params = new URLSearchParams({next: nextUrl});
return `${djangoUrls.adminLogin}?${params}`;
};
Loading