🛂(frontend) access public docs without being logged
We can now access public docs without being logged.
This commit is contained in:
@@ -9,6 +9,11 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 🛂(frontend) access public docs without being logged #235
|
||||||
|
|
||||||
|
|
||||||
## [1.3.0] - 2024-09-05
|
## [1.3.0] - 2024-09-05
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { keyCloakSignIn } from './common';
|
import { createDoc, keyCloakSignIn } from './common';
|
||||||
|
|
||||||
test.describe('Doc Routing', () => {
|
test.describe('Doc Routing', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -47,4 +47,43 @@ test.describe('Doc Routing: Not loggued', () => {
|
|||||||
await keyCloakSignIn(page, browserName);
|
await keyCloakSignIn(page, browserName);
|
||||||
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
|
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('The homepage redirects to login.', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'Sign In',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A public doc is accessible even when not authentified.', async ({
|
||||||
|
page,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await keyCloakSignIn(page, browserName);
|
||||||
|
|
||||||
|
const [docTitle] = await createDoc(
|
||||||
|
page,
|
||||||
|
'My new doc',
|
||||||
|
browserName,
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlDoc = page.url();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Logout',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(urlDoc);
|
||||||
|
|
||||||
|
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ test.describe('Header', () => {
|
|||||||
/Marianne/i,
|
/Marianne/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
header.getByRole('button', {
|
||||||
|
name: 'Logout',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(header.getByAltText('Language Icon')).toBeVisible();
|
await expect(header.getByAltText('Language Icon')).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -68,12 +74,6 @@ test.describe('Header: Log out', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await keyCloakSignIn(page, browserName);
|
await keyCloakSignIn(page, browserName);
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole('button', {
|
|
||||||
name: 'My account',
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('button', {
|
||||||
name: 'Logout',
|
name: 'Logout',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
|
|
||||||
import { fetchAPI } from '@/api';
|
import { fetchAPI } from '@/api';
|
||||||
import { useAuthStore } from '@/core/auth';
|
|
||||||
|
|
||||||
describe('fetchAPI', () => {
|
describe('fetchAPI', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -30,19 +29,6 @@ describe('fetchAPI', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logout if 401 response', async () => {
|
|
||||||
const logoutMock = jest.fn();
|
|
||||||
jest
|
|
||||||
.spyOn(useAuthStore.getState(), 'logout')
|
|
||||||
.mockImplementation(logoutMock);
|
|
||||||
|
|
||||||
fetchMock.mock('http://test.jest/api/v1.0/some/url', 401);
|
|
||||||
|
|
||||||
await fetchAPI('some/url');
|
|
||||||
|
|
||||||
expect(logoutMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('check the versionning', () => {
|
it('check the versionning', () => {
|
||||||
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
|
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { baseApiUrl, useAuthStore } from '@/core';
|
import { baseApiUrl } from '@/core';
|
||||||
|
|
||||||
import { getCSRFToken } from './utils';
|
import { getCSRFToken } from './utils';
|
||||||
|
|
||||||
@@ -30,10 +30,5 @@ export const fetchAPI = async (
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
const { logout } = useAuthStore.getState();
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import '@/i18n/initI18n';
|
import '@/i18n/initI18n';
|
||||||
|
|
||||||
import { Auth } from './auth/Auth';
|
import { Auth } from './auth/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryClient:
|
* QueryClient:
|
||||||
|
|||||||
34
src/frontend/apps/impress/src/core/auth/AccountDropdown.tsx
Normal file
34
src/frontend/apps/impress/src/core/auth/AccountDropdown.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button } from '@openfun/cunningham-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/core/auth';
|
||||||
|
|
||||||
|
export const AccountDropdown = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { logout, authenticated, login } = useAuthStore();
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={login}
|
||||||
|
color="primary-text"
|
||||||
|
icon={<span className="material-icons">login</span>}
|
||||||
|
aria-label={t('Login')}
|
||||||
|
>
|
||||||
|
{t('Login')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={logout}
|
||||||
|
color="primary-text"
|
||||||
|
icon={<span className="material-icons">logout</span>}
|
||||||
|
aria-label={t('Logout')}
|
||||||
|
>
|
||||||
|
{t('Logout')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,18 +1,47 @@
|
|||||||
import { Loader } from '@openfun/cunningham-react';
|
import { Loader } from '@openfun/cunningham-react';
|
||||||
import { PropsWithChildren, useEffect } from 'react';
|
import { useRouter } from 'next/router';
|
||||||
|
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
|
|
||||||
import { useAuthStore } from './useAuthStore';
|
import { useAuthStore } from './useAuthStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Remove this restriction when we will have a homepage design for non-authenticated users.
|
||||||
|
*
|
||||||
|
* We define the paths that are not allowed without authentication.
|
||||||
|
* Actually, only the home page and the docs page are not allowed without authentication.
|
||||||
|
* When we will have a homepage design for non-authenticated users, we will remove this restriction to have
|
||||||
|
* the full website accessible without authentication.
|
||||||
|
*/
|
||||||
|
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
|
||||||
|
|
||||||
export const Auth = ({ children }: PropsWithChildren) => {
|
export const Auth = ({ children }: PropsWithChildren) => {
|
||||||
const { authenticated, initAuth } = useAuthStore();
|
const { initAuth, initiated, authenticated, login } = useAuthStore();
|
||||||
|
const { asPath } = useRouter();
|
||||||
|
|
||||||
|
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
||||||
|
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initAuth();
|
initAuth();
|
||||||
}, [initAuth]);
|
}, [initAuth]);
|
||||||
|
|
||||||
if (!authenticated) {
|
useEffect(() => {
|
||||||
|
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
|
||||||
|
}, [asPath]);
|
||||||
|
|
||||||
|
// We force to login except on allowed paths
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiated || authenticated || pathAllowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
login();
|
||||||
|
}, [authenticated, pathAllowed, login, initiated]);
|
||||||
|
|
||||||
|
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
|
||||||
return (
|
return (
|
||||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||||
<Loader />
|
<Loader />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './AccountDropdown';
|
||||||
|
export * from './api/types';
|
||||||
export * from './Auth';
|
export * from './Auth';
|
||||||
export * from './useAuthStore';
|
export * from './useAuthStore';
|
||||||
export * from './api/types';
|
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import { User, getMe } from './api';
|
|||||||
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
|
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
|
||||||
|
|
||||||
interface AuthStore {
|
interface AuthStore {
|
||||||
|
initiated: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
initAuth: () => void;
|
initAuth: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
login: () => void;
|
||||||
userData?: User;
|
userData?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
|
initiated: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userData: undefined,
|
userData: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuthStore = create<AuthStore>((set) => ({
|
export const useAuthStore = create<AuthStore>((set) => ({
|
||||||
|
initiated: initialState.initiated,
|
||||||
authenticated: initialState.authenticated,
|
authenticated: initialState.authenticated,
|
||||||
userData: initialState.userData,
|
userData: initialState.userData,
|
||||||
|
|
||||||
@@ -34,20 +38,21 @@ export const useAuthStore = create<AuthStore>((set) => ({
|
|||||||
|
|
||||||
set({ authenticated: true, userData: data });
|
set({ authenticated: true, userData: data });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {})
|
||||||
// If we try to access a specific page and we are not authenticated
|
.finally(() => {
|
||||||
// we store the path in the local storage to redirect to it after login
|
set({ initiated: true });
|
||||||
if (window.location.pathname !== '/') {
|
|
||||||
localStorage.setItem(
|
|
||||||
PATH_AUTH_LOCAL_STORAGE,
|
|
||||||
window.location.pathname,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.replace(new URL('authenticate/', baseApiUrl()).href);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
login: () => {
|
||||||
|
// If we try to access a specific page and we are not authenticated
|
||||||
|
// we store the path in the local storage to redirect to it after login
|
||||||
|
if (window.location.pathname !== '/') {
|
||||||
|
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace(`${baseApiUrl()}authenticate/`);
|
||||||
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
window.location.replace(new URL('logout/', baseApiUrl()).href);
|
window.location.replace(`${baseApiUrl()}logout/`);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button } from '@openfun/cunningham-react';
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { Box, DropButton, Text } from '@/components';
|
|
||||||
import { useAuthStore } from '@/core/auth';
|
|
||||||
|
|
||||||
export const AccountDropdown = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { logout } = useAuthStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropButton
|
|
||||||
aria-label={t('My account')}
|
|
||||||
button={
|
|
||||||
<Box $flex $direction="row" $align="center">
|
|
||||||
<Text $theme="primary">{t('My account')}</Text>
|
|
||||||
<Text className="material-icons" $theme="primary">
|
|
||||||
arrow_drop_down
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={logout}
|
|
||||||
color="primary-text"
|
|
||||||
icon={<span className="material-icons">logout</span>}
|
|
||||||
aria-label={t('Logout')}
|
|
||||||
>
|
|
||||||
<Text $weight="normal">{t('Logout')}</Text>
|
|
||||||
</Button>
|
|
||||||
</DropButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,11 +4,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { Box, StyledLink, Text } from '@/components/';
|
import { Box, StyledLink, Text } from '@/components/';
|
||||||
|
import { AccountDropdown } from '@/core/auth';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
|
|
||||||
import { LanguagePicker } from '../language/';
|
import { LanguagePicker } from '../language/';
|
||||||
|
|
||||||
import { AccountDropdown } from './AccountDropdown';
|
|
||||||
import { LaGaufre } from './LaGaufre';
|
import { LaGaufre } from './LaGaufre';
|
||||||
import { default as IconDocs } from './assets/icon-docs.svg?url';
|
import { default as IconDocs } from './assets/icon-docs.svg?url';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Head, Html, Main, NextScript } from 'next/document';
|
import { Head, Html, Main, NextScript } from 'next/document';
|
||||||
|
|
||||||
import '@/i18n/initI18n';
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<Html lang="en">
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
|
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
|
||||||
<Main />
|
<Main />
|
||||||
|
|||||||
Reference in New Issue
Block a user