🐛(frontend) fix 404 page when reload 403 page
When users were reloading a 403 page, they were redirected to the 404 page because of Nextjs routing mechanism. This commit fixes this issue by removing the 403 page from the pages directory and creating a component that is used directly in the layout when a 403 error is detected.
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -13,20 +13,13 @@ and this project adheres to
|
|||||||
- ♿(frontend) improve accessibility:
|
- ♿(frontend) improve accessibility:
|
||||||
- #1354
|
- #1354
|
||||||
- ♿ improve accessibility by adding landmark roles to layout #1394
|
- ♿ improve accessibility by adding landmark roles to layout #1394
|
||||||
|
- ✨ add document visible in list and openable via enter key #1365
|
||||||
|
- ♿ add pdf outline property to enable bookmarks display #1368
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🐛(backend) duplicate sub docs as root for reader users
|
- 🐛(backend) duplicate sub docs as root for reader users
|
||||||
|
- 🐛(frontend) fix 404 page when reload 403 page #1402
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿(frontend) improve accessibility:
|
|
||||||
- ✨ add document visible in list and openable via enter key #1365
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿(frontend) improve accessibility:
|
|
||||||
- ♿ add pdf outline property to enable bookmarks display #1368
|
|
||||||
|
|
||||||
## [3.7.0] - 2025-09-12
|
## [3.7.0] - 2025-09-12
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
randomName,
|
randomName,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
} from './utils-common';
|
} from './utils-common';
|
||||||
|
import { connectOtherUserToDoc } from './utils-share';
|
||||||
import { createRootSubPage } from './utils-sub-pages';
|
import { createRootSubPage } from './utils-sub-pages';
|
||||||
|
|
||||||
test.describe('Document create member', () => {
|
test.describe('Document create member', () => {
|
||||||
@@ -209,10 +210,6 @@ test.describe('Document create member', () => {
|
|||||||
|
|
||||||
await expect(userInvitation).toBeHidden();
|
await expect(userInvitation).toBeHidden();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Document create member: Multiple login', () => {
|
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
|
||||||
|
|
||||||
test('It creates a member from a request coming from a 403 page', async ({
|
test('It creates a member from a request coming from a 403 page', async ({
|
||||||
page,
|
page,
|
||||||
@@ -220,9 +217,6 @@ test.describe('Document create member: Multiple login', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
test.slow();
|
test.slow();
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
await keyCloakSignIn(page, browserName);
|
|
||||||
|
|
||||||
const [docTitle] = await createDoc(
|
const [docTitle] = await createDoc(
|
||||||
page,
|
page,
|
||||||
'Member access request',
|
'Member access request',
|
||||||
@@ -232,55 +226,37 @@ test.describe('Document create member: Multiple login', () => {
|
|||||||
|
|
||||||
await verifyDocName(page, docTitle);
|
await verifyDocName(page, docTitle);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('.ProseMirror')
|
||||||
|
.locator('.bn-block-outer')
|
||||||
|
.last()
|
||||||
|
.fill('Hello World');
|
||||||
|
|
||||||
const urlDoc = page.url();
|
const urlDoc = page.url();
|
||||||
|
|
||||||
await page
|
// Other user will request access
|
||||||
.getByRole('button', {
|
const { otherPage, otherBrowserName, cleanup } =
|
||||||
name: 'Logout',
|
await connectOtherUserToDoc(browserName, urlDoc);
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
const otherBrowser = BROWSERS.find((b) => b !== browserName);
|
|
||||||
|
|
||||||
await keyCloakSignIn(page, otherBrowser!);
|
|
||||||
|
|
||||||
await expect(page.getByTestId('header-logo-link')).toBeVisible();
|
|
||||||
|
|
||||||
await page.goto(urlDoc);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Insufficient access rights to view the document.'),
|
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||||
).toBeVisible({
|
).toBeVisible({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Request access' }).click();
|
await otherPage.getByRole('button', { name: 'Request access' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Your access request for this document is pending.'),
|
otherPage.getByText('Your access request for this document is pending.'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page
|
// First user approves the request
|
||||||
.getByRole('button', {
|
|
||||||
name: 'Logout',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
await keyCloakSignIn(page, browserName);
|
|
||||||
|
|
||||||
await expect(page.getByTestId('header-logo-link')).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(urlDoc);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Access Requests')).toBeVisible();
|
await expect(page.getByText('Access Requests')).toBeVisible();
|
||||||
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
|
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
|
||||||
|
|
||||||
const emailRequest = `user.test@${otherBrowser}.test`;
|
const emailRequest = `user.test@${otherBrowserName}.test`;
|
||||||
await expect(page.getByText(emailRequest)).toBeVisible();
|
await expect(page.getByText(emailRequest)).toBeVisible();
|
||||||
const container = page.getByTestId(
|
const container = page.getByTestId(
|
||||||
`doc-share-access-request-row-${emailRequest}`,
|
`doc-share-access-request-row-${emailRequest}`,
|
||||||
@@ -291,8 +267,20 @@ test.describe('Document create member: Multiple login', () => {
|
|||||||
|
|
||||||
await expect(page.getByText('Access Requests')).toBeHidden();
|
await expect(page.getByText('Access Requests')).toBeHidden();
|
||||||
await expect(page.getByText('Share with 2 users')).toBeVisible();
|
await expect(page.getByText('Share with 2 users')).toBeVisible();
|
||||||
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
|
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
|
||||||
|
|
||||||
|
// Other user verifies he has access
|
||||||
|
await otherPage.reload();
|
||||||
|
await verifyDocName(otherPage, docTitle);
|
||||||
|
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||||
|
|
||||||
|
// Cleanup: other user logout
|
||||||
|
await cleanup();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Document create member: Multiple login', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test('It cannot request member access on child doc on a 403 page', async ({
|
test('It cannot request member access on child doc on a 403 page', async ({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
keyCloakSignIn,
|
keyCloakSignIn,
|
||||||
verifyDocName,
|
verifyDocName,
|
||||||
} from './utils-common';
|
} from './utils-common';
|
||||||
|
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||||
import { createRootSubPage } from './utils-sub-pages';
|
import { createRootSubPage } from './utils-sub-pages';
|
||||||
|
|
||||||
test.describe('Doc Visibility', () => {
|
test.describe('Doc Visibility', () => {
|
||||||
@@ -146,47 +147,31 @@ test.describe('Doc Visibility: Restricted', () => {
|
|||||||
|
|
||||||
await verifyDocName(page, docTitle);
|
await verifyDocName(page, docTitle);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share' }).click();
|
await page
|
||||||
|
.locator('.ProseMirror')
|
||||||
const inputSearch = page.getByRole('combobox', {
|
.locator('.bn-block-outer')
|
||||||
name: 'Quick search input',
|
.last()
|
||||||
});
|
.fill('Hello World');
|
||||||
|
|
||||||
const otherBrowser = BROWSERS.find((b) => b !== browserName);
|
|
||||||
if (!otherBrowser) {
|
|
||||||
throw new Error('No alternative browser found');
|
|
||||||
}
|
|
||||||
const username = `user.test@${otherBrowser}.test`;
|
|
||||||
await inputSearch.fill(username);
|
|
||||||
await page.getByRole('option', { name: username }).click();
|
|
||||||
|
|
||||||
// Choose a role
|
|
||||||
const container = page.getByTestId('doc-share-add-member-list');
|
|
||||||
await container.getByLabel('doc-role-dropdown').click();
|
|
||||||
await page.getByLabel('Reader').click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Invite' }).click();
|
|
||||||
|
|
||||||
await page.locator('.c__modal__backdrop').click({
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const urlDoc = page.url();
|
const urlDoc = page.url();
|
||||||
|
|
||||||
await page
|
const { otherBrowserName, otherPage } = await connectOtherUserToDoc(
|
||||||
.getByRole('button', {
|
browserName,
|
||||||
name: 'Logout',
|
urlDoc,
|
||||||
})
|
);
|
||||||
.click();
|
|
||||||
|
|
||||||
await keyCloakSignIn(page, otherBrowser);
|
await expect(
|
||||||
|
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(page.getByTestId('header-logo-link')).toBeVisible();
|
await page.getByRole('button', { name: 'Share' }).click();
|
||||||
|
|
||||||
await page.goto(urlDoc);
|
await addNewMember(page, 0, 'Reader', otherBrowserName);
|
||||||
|
|
||||||
await verifyDocName(page, docTitle);
|
await otherPage.reload();
|
||||||
await expect(page.getByLabel('Share button')).toBeVisible();
|
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Page, expect } from '@playwright/test';
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
|
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||||
|
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
|
||||||
|
|
||||||
export const CONFIG = {
|
export const CONFIG = {
|
||||||
AI_FEATURE_ENABLED: true,
|
AI_FEATURE_ENABLED: true,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Page, expect } from '@playwright/test';
|
import { Page, chromium, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BROWSERS,
|
||||||
|
BrowserName,
|
||||||
|
keyCloakSignIn,
|
||||||
|
verifyDocName,
|
||||||
|
} from './utils-common';
|
||||||
|
|
||||||
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
||||||
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
||||||
@@ -61,6 +68,56 @@ export const updateShareLink = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects another user to a document.
|
||||||
|
* Useful to test real-time collaboration features.
|
||||||
|
* @param browserName The name of the browser to use.
|
||||||
|
* @param docUrl The URL of the document to connect to.
|
||||||
|
* @param docTitle The title of the document (optional).
|
||||||
|
* @returns An object containing the other browser, context, and page.
|
||||||
|
*/
|
||||||
|
export const connectOtherUserToDoc = async (
|
||||||
|
browserName: BrowserName,
|
||||||
|
docUrl: string,
|
||||||
|
docTitle?: string,
|
||||||
|
) => {
|
||||||
|
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
|
||||||
|
if (!otherBrowserName) {
|
||||||
|
throw new Error('No alternative browser found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherBrowser = await chromium.launch({ headless: true });
|
||||||
|
const otherContext = await otherBrowser.newContext({
|
||||||
|
locale: 'en-US',
|
||||||
|
timezoneId: 'Europe/Paris',
|
||||||
|
permissions: [],
|
||||||
|
storageState: {
|
||||||
|
cookies: [],
|
||||||
|
origins: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const otherPage = await otherContext.newPage();
|
||||||
|
await otherPage.goto(docUrl);
|
||||||
|
|
||||||
|
await otherPage.getByRole('button', { name: 'Login' }).click({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await keyCloakSignIn(otherPage, otherBrowserName, false);
|
||||||
|
|
||||||
|
if (docTitle) {
|
||||||
|
await verifyDocName(otherPage, docTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
await otherPage.close();
|
||||||
|
await otherContext.close();
|
||||||
|
await otherBrowser.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { otherBrowser, otherContext, otherPage, otherBrowserName, cleanup };
|
||||||
|
};
|
||||||
|
|
||||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||||
let result = [
|
let result = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,50 +1,23 @@
|
|||||||
import { Button } from '@openfun/cunningham-react';
|
import { Button } from '@openfun/cunningham-react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import img403 from '@/assets/icons/icon-403.png';
|
import img403 from '@/assets/icons/icon-403.png';
|
||||||
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
||||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
|
||||||
import { KEY_DOC, useDoc } from '@/docs/doc-management';
|
|
||||||
import { ButtonAccessRequest } from '@/docs/doc-share';
|
import { ButtonAccessRequest } from '@/docs/doc-share';
|
||||||
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
|
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
|
||||||
import { MainLayout } from '@/layouts';
|
|
||||||
import { NextPageWithLayout } from '@/types/next';
|
|
||||||
|
|
||||||
const StyledButton = styled(Button)`
|
const StyledButton = styled(Button)`
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function DocLayout() {
|
|
||||||
const {
|
|
||||||
query: { id },
|
|
||||||
} = useRouter();
|
|
||||||
|
|
||||||
if (typeof id !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<MainLayout>
|
|
||||||
<DocPage403 id={id} />
|
|
||||||
</MainLayout>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocProps {
|
interface DocProps {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocPage403 = ({ id }: DocProps) => {
|
export const DocPage403 = ({ id }: DocProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
data: requests,
|
data: requests,
|
||||||
@@ -54,39 +27,19 @@ const DocPage403 = ({ id }: DocProps) => {
|
|||||||
docId: id,
|
docId: id,
|
||||||
page: 1,
|
page: 1,
|
||||||
});
|
});
|
||||||
const { replace } = useRouter();
|
|
||||||
|
|
||||||
const hasRequested = !!requests?.results.find(
|
const hasRequested = !!requests?.results.find(
|
||||||
(request) => request.document === id,
|
(request) => request.document === id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { error: docError, isLoading: isLoadingDoc } = useDoc(
|
if (isLoadingRequest) {
|
||||||
{ id },
|
|
||||||
{
|
|
||||||
staleTime: 0,
|
|
||||||
queryKey: [KEY_DOC, { id }],
|
|
||||||
retry: (failureCount, error) => {
|
|
||||||
if (error.status == 403) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
return failureCount < DEFAULT_QUERY_RETRY;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isLoadingDoc && docError?.status !== 403) {
|
|
||||||
void replace(`/docs/${id}`);
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadingDoc || isLoadingRequest) {
|
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
<title>
|
<title>
|
||||||
{t('Access Denied - Error 403')} - {t('Docs')}
|
{t('Access Denied - Error 403')} - {t('Docs')}
|
||||||
</title>
|
</title>
|
||||||
@@ -152,13 +105,3 @@ const DocPage403 = ({ id }: DocProps) => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Page: NextPageWithLayout = () => {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
Page.getLayout = function getLayout() {
|
|
||||||
return <DocLayout />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './DocPage403';
|
||||||
export * from './ModalRemoveDoc';
|
export * from './ModalRemoveDoc';
|
||||||
export * from './SimpleDocItem';
|
export * from './SimpleDocItem';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DEFAULT_QUERY_RETRY } from '@/core';
|
|||||||
import { DocEditor } from '@/docs/doc-editor';
|
import { DocEditor } from '@/docs/doc-editor';
|
||||||
import {
|
import {
|
||||||
Doc,
|
Doc,
|
||||||
|
DocPage403,
|
||||||
KEY_DOC,
|
KEY_DOC,
|
||||||
useCollaboration,
|
useCollaboration,
|
||||||
useDoc,
|
useDoc,
|
||||||
@@ -118,7 +119,7 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
}, [addTask, doc?.id, queryClient]);
|
}, [addTask, doc?.id, queryClient]);
|
||||||
|
|
||||||
if (isError && error) {
|
if (isError && error) {
|
||||||
if ([403, 404, 401].includes(error.status)) {
|
if ([404, 401].includes(error.status)) {
|
||||||
let replacePath = `/${error.status}`;
|
let replacePath = `/${error.status}`;
|
||||||
|
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
@@ -129,8 +130,6 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setAuthUrl();
|
setAuthUrl();
|
||||||
} else if (error.status === 403) {
|
|
||||||
replacePath = `/docs/${id}/403`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void replace(replacePath);
|
void replace(replacePath);
|
||||||
@@ -138,6 +137,10 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error.status === 403) {
|
||||||
|
return <DocPage403 id={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box $margin="large">
|
<Box $margin="large">
|
||||||
<TextErrors
|
<TextErrors
|
||||||
|
|||||||
Reference in New Issue
Block a user