✨(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:
committed by
aleb_the_flash
parent
7db2faa072
commit
9ec7eddaed
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
initAuth();
|
||||
}, [initAuth]);
|
||||
}, [initAuth, authenticated]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './getMe';
|
||||
export * from './types';
|
||||
export * from './getMe';
|
||||
export * from './logout';
|
||||
|
||||
8
src/frontend/apps/desk/src/core/auth/api/logout.ts
Normal file
8
src/frontend/apps/desk/src/core/auth/api/logout.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { fetchAPI } from '@/api';
|
||||
|
||||
export const logout = async () => {
|
||||
await fetchAPI(`logout/`, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 |
@@ -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...",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user