(frontend) add logout button

Rework the header based on latest Johann's design, which introduced a
dropdown menu to mange user account.

In this menu, you can find a logout button, which ends up the backend
session by calling the logout endpoint. Please that automatic redirection
when receiving the backend response were disabled. We handle it in our
custom hook, which reload the page.

Has the session cookie have been cleared, on reloading the page, a new
loggin flow is initiated, and the user is redirected to the OIDC provider.

Please note, the homepage design/organization is still under discussion, I
prefered to ship a first increment. The logout feature will be quite useful
in staging to play and test our UI.
This commit is contained in:
Lebaud Antoine
2024-03-25 23:06:18 +01:00
committed by aleb_the_flash
parent 7db2faa072
commit 9ec7eddaed
12 changed files with 89 additions and 53 deletions

View File

@@ -34,25 +34,22 @@ describe('fetchAPI', () => {
});
it('logout if 401 response', async () => {
const mockReplace = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
enumerable: true,
value: {
replace: mockReplace,
},
useAuthStore.setState({
authenticated: true,
userData: { id: '123', email: 'test@test.com' },
});
useAuthStore.setState({ userData: { email: 'test@test.com', id: '1234' } });
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
fetchMock.mock('http://some.api.url/api/v1.0/logout/', 302);
await fetchAPI('some/url');
expect(useAuthStore.getState().userData).toBeUndefined();
await Promise.all([fetchMock.flush()]);
expect(mockReplace).toHaveBeenCalledWith(
'http://some.api.url/api/v1.0/authenticate/',
);
expect(fetchMock.lastUrl()).toEqual('http://some.api.url/api/v1.0/logout/');
const { userData, authenticated } = useAuthStore.getState();
expect(userData).toBeUndefined();
expect(authenticated).toBeFalsy();
});
});

View File

@@ -1,4 +1,4 @@
import { login, useAuthStore } from '@/core/auth';
import { useAuthStore } from '@/core/auth';
/**
* Retrieves the CSRF token from the document's cookies.
@@ -29,12 +29,8 @@ export const fetchAPI = async (input: string, init?: RequestInit) => {
},
});
// todo - handle 401, redirect to login screen
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
if (response.status === 401) {
logout();
// Fix - force re-logging the user, will be refactored
login();
}
return response;

View File

@@ -23,6 +23,10 @@ const StyledButton = styled(Button)`
background: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: Marianne, Arial, serif;
font-weight: 500;
font-size: 0.938rem;
text-wrap: nowrap;
`;
interface DropButtonProps {

View File

@@ -10,7 +10,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
useEffect(() => {
initAuth();
}, [initAuth]);
}, [initAuth, authenticated]);
if (!authenticated) {
return (

View File

@@ -1,2 +1,3 @@
export * from './getMe';
export * from './types';
export * from './getMe';
export * from './logout';

View File

@@ -0,0 +1,8 @@
import { fetchAPI } from '@/api';
export const logout = async () => {
await fetchAPI(`logout/`, {
method: 'POST',
redirect: 'manual',
});
};

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { User, getMe } from './api';
import { User, getMe, logout } from './api';
export const login = () => {
window.location.replace(
@@ -30,11 +30,12 @@ export const useAuthStore = create<AuthStore>((set) => ({
set({ authenticated: true, userData: data });
})
.catch(() => {
// todo - implement a proper login screen to prevent automatic navigation.
login();
});
},
logout: () => {
set(initialState);
void logout().then(() => {
set(initialState);
});
},
}));

View File

@@ -0,0 +1,34 @@
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>
);
};

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Text } from '@/components/';
import { AccountDropdown } from '@/features/header/AccountDropdown';
import { ApplicationsMenu } from '@/features/header/ApplicationsMenu';
import { LanguagePicker } from '../language/';
@@ -11,7 +12,6 @@ import { LanguagePicker } from '../language/';
import { default as IconApplication } from './assets/icon-application.svg?url';
import { default as IconGouv } from './assets/icon-gouv.svg?url';
import { default as IconMarianne } from './assets/icon-marianne.svg?url';
import IconMyAccount from './assets/icon-my-account.png';
export const HEADER_HEIGHT = '100px';
@@ -62,35 +62,10 @@ export const Header = () => {
</Box>
</Box>
</Box>
<Box
$align="center"
$css={`
& > button {
padding: 0;
}
`}
$gap="5rem"
$justify="flex-end"
$direction="row"
>
<Box $align="center" $direction="row">
<LanguagePicker />
</Box>
<Box $direction="row" $align="center">
<Box $direction="row" $align="center" $gap="1rem">
<Text $weight="bold" $theme="primary">
John Doe
</Text>
<Image
width={58}
height={58}
priority
src={IconMyAccount}
alt={t(`Profile picture`)}
/>
</Box>
<ApplicationsMenu />
</Box>
<Box $align="center" $gap="1rem" $justify="flex-end" $direction="row">
<AccountDropdown />
<LanguagePicker />
<ApplicationsMenu />
</Box>
</Box>
</StyledHeader>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -56,11 +56,13 @@
"Language Icon": "Icône de langue",
"Last update at": "Dernière modification le",
"List members card": "Carte liste des membres",
"Logout": "Déconnexion",
"Marianne Logo": "Logo Marianne",
"Member": "Membre",
"Member icon": "Icône de membre",
"Member {{name}} added to the team": "Membre {{name}} ajouté au groupe",
"Members of “{{teamName}}“": "Membres de “{{teamName}}“",
"My account": "Mon compte",
"Name the team": "Nommer le groupe",
"Names": "Noms",
"New name...": "Nouveau nom...",

View File

@@ -34,4 +34,22 @@ test.describe('Header', () => {
await expect(header.getByAltText('Language Icon')).toBeVisible();
await expect(header.getByText('My account')).toBeVisible();
});
test('checks logout button', async ({ page }) => {
await page
.getByRole('button', {
name: 'My account',
})
.click();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
// FIXME - assert the session has been killed in Keycloak
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
});