💄(oidc) add login page in the frontend

To have a better user experience, we want the login page
to in the frontend.
This commit is contained in:
Quentin BEY
2025-02-10 11:43:30 +01:00
committed by BEY Quentin
parent 68550f6f7e
commit fd8e0e08c3
13 changed files with 516 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
@@ -7,12 +8,16 @@ import { useAuthStore } from './useAuthStore';
export const Auth = ({ children }: PropsWithChildren) => {
const { authenticated, initAuth } = useAuthStore();
const router = useRouter();
const isLoginPage = router.pathname === '/login';
useEffect(() => {
initAuth();
}, [initAuth]);
if (!isLoginPage) {
initAuth();
}
}, [initAuth, isLoginPage]);
if (!authenticated) {
if (!authenticated && !isLoginPage) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />

View File

@@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppWrapper } from '@/tests/utils';
import { InputUserEmail } from '../components/InputUserEmail';
describe('InputUserEmail', () => {
const mockSetEmail = jest.fn();
const defaultProps = {
label: 'Email Address',
email: '',
setEmail: mockSetEmail,
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderInput = (props = defaultProps) => {
render(<InputUserEmail {...props} />, { wrapper: AppWrapper });
};
it('renders the email input with correct label', () => {
renderInput();
expect(screen.getByLabelText('Email Address')).toBeInTheDocument();
});
it('calls setEmail when input value changes', async () => {
renderInput();
const input = screen.getByLabelText('Email Address');
await userEvent.type(input, 'test@example.com');
expect(mockSetEmail).toHaveBeenCalledWith('test@example.com');
});
it('displays the current email value', () => {
renderInput({ ...defaultProps, email: 'test@example.com' });
const input: HTMLInputElement = screen.getByLabelText('Email Address');
expect(input.value).toBe('test@example.com');
});
it('has required attribute', () => {
renderInput();
const input = screen.getByLabelText('Email Address');
expect(input).toHaveAttribute('required');
});
it('has correct type and autocomplete attributes', () => {
renderInput();
const input = screen.getByLabelText('Email Address');
expect(input).toHaveAttribute('type', 'email');
expect(input).toHaveAttribute('autocomplete', 'username');
});
});

View File

@@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppWrapper } from '@/tests/utils';
import { InputUserPassword } from '../components/InputUserPassword';
describe('InputUserPassword', () => {
const mockSetPassword = jest.fn();
const defaultProps = {
label: 'Password',
setPassword: mockSetPassword,
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderInput = (props = defaultProps) => {
render(<InputUserPassword {...props} />, { wrapper: AppWrapper });
};
it('renders the password input with correct label', () => {
renderInput();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('calls setPassword when input value changes', async () => {
renderInput();
const input = screen.getByLabelText('Password');
await userEvent.type(input, 'mypassword123');
expect(mockSetPassword).toHaveBeenCalledWith('mypassword123');
});
it('has required attribute', () => {
renderInput();
const input = screen.getByLabelText('Password');
expect(input).toHaveAttribute('required');
});
it('has correct type and autocomplete attributes', () => {
renderInput();
const input = screen.getByLabelText('Password');
expect(input).toHaveAttribute('type', 'password');
expect(input).toHaveAttribute('autocomplete', 'current-password');
});
});

View File

@@ -0,0 +1,117 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppWrapper } from '@/tests/utils';
import { LoginForm } from '../components/LoginForm';
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
}));
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
describe('LoginForm', () => {
const mockHandleSubmit = jest.fn((e) => e.preventDefault());
const mockSetEmail = jest.fn();
const mockSetPassword = jest.fn();
const defaultProps = {
title: 'Login',
labelEmail: 'Email',
labelPassword: 'Password',
labelSignIn: 'Sign In',
email: '',
setEmail: mockSetEmail,
setPassword: mockSetPassword,
error: '',
handleSubmit: mockHandleSubmit,
blockingError: '',
};
beforeEach(() => {
jest.clearAllMocks();
});
const renderLoginForm = () => {
render(<LoginForm {...defaultProps} />, { wrapper: AppWrapper });
};
it('should render the login form', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByText('Login')).toBeInTheDocument();
});
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByText('Sign In')).toBeInTheDocument();
});
it('should handle email input', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
const emailInput = screen.getByLabelText('Email');
await userEvent.type(emailInput, 'test@example.com');
expect(mockSetEmail).toHaveBeenCalledWith('test@example.com');
});
it('should handle password input', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
const passwordInput = screen.getByLabelText('Password');
await userEvent.type(passwordInput, 'password123');
expect(mockSetPassword).toHaveBeenCalledWith('password123');
});
it('should submit the form', async () => {
renderLoginForm();
await waitFor(() => {
expect(screen.getByText('Sign In')).toBeInTheDocument();
});
const form = screen.getByTestId('login-form');
fireEvent.submit(form);
expect(mockHandleSubmit).toHaveBeenCalled();
});
it('should display error message when provided', async () => {
const errorMessage = 'Invalid credentials';
render(<LoginForm {...defaultProps} error={errorMessage} />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should display blocking error and hide form when provided', async () => {
const blockingError = 'Service unavailable';
render(<LoginForm {...defaultProps} blockingError={blockingError} />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(screen.getByText(blockingError)).toBeInTheDocument();
});
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,25 @@
import { Input } from '@openfun/cunningham-react';
interface InputUserEmailProps {
label: string;
email: string;
setEmail: (newEmail: string) => void;
}
export const InputUserEmail = ({
label,
email,
setEmail,
}: InputUserEmailProps) => {
return (
<Input
label={label}
type="email"
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
autoComplete="username"
value={email}
/>
);
};

View File

@@ -0,0 +1,22 @@
import { Input } from '@openfun/cunningham-react';
interface InputUserEmailProps {
label: string;
setPassword: (newEmail: string) => void;
}
export const InputUserPassword = ({
label,
setPassword,
}: InputUserEmailProps) => {
return (
<Input
label={label}
type="password"
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
autoComplete="current-password"
/>
);
};

View File

@@ -0,0 +1,95 @@
import { Button } from '@openfun/cunningham-react';
import { Box, Text } from '@/components';
import { InputUserEmail, InputUserPassword } from '@/features/login';
interface LoginFormProps {
title: string;
labelEmail: string;
labelPassword: string;
labelSignIn: string;
email: string;
setEmail: (newEmail: string) => void;
setPassword: (newPassword: string) => void;
error: string;
handleSubmit: (e: React.FormEvent) => void;
blockingError: string;
}
export const LoginForm = ({
title,
labelEmail,
labelPassword,
labelSignIn,
email,
setEmail,
setPassword,
error,
handleSubmit,
blockingError,
}: LoginFormProps) => {
return (
<Box $width="100%" $maxWidth="30rem" $margin="4rem auto" $padding="0 1rem">
<Box>
<Text
as="h1"
$textAlign="center"
$size="h3"
$theme="primary"
$variation="text"
style={{ marginBottom: '2rem' }}
>
{title}
</Text>
<Box>
{!!blockingError ? (
<Text
$theme="danger"
$variation="text"
$textAlign="center"
style={{ marginBottom: '1rem', display: 'block' }}
>
{blockingError === 'loading' ? '' : blockingError}
</Text>
) : (
<form onSubmit={handleSubmit} data-testId="login-form">
<Box $padding="tiny">
<InputUserEmail
setEmail={setEmail}
email={email}
label={labelEmail}
/>
</Box>
<Box $padding="tiny">
<InputUserPassword
label={labelPassword}
setPassword={setPassword}
/>
</Box>
{error && (
<Box $padding="tiny">
<Text
$theme="danger"
$variation="text"
$textAlign="center"
style={{ marginBottom: '1rem', display: 'block' }}
>
{error}
</Text>
</Box>
)}
<Box $padding="tiny">
<Button color="primary" type="submit" fullWidth>
{labelSignIn}
</Button>
</Box>
</form>
)}
</Box>{' '}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,19 @@
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { Footer } from '@/features/footer/Footer';
import { Header } from '@/features/header';
export function LoginLayout({ children }: PropsWithChildren) {
return (
<Box>
<Box $height="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
{children}
</Box>
<Footer />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,4 @@
export * from './LoginLayout';
export * from './LoginForm';
export * from './InputUserEmail';
export * from './InputUserPassword';

View File

@@ -0,0 +1 @@
export * from './components';

View File

@@ -0,0 +1,95 @@
import { useRouter } from 'next/router';
import { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fetchAPI } from '@/api';
import { LoginForm, LoginLayout } from '@/features/login';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const router = useRouter();
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [blockingError, setBlockingError] = useState('loading');
const { next } = router.query;
useEffect(() => {
if (next) {
try {
// Decode the URL-encoded next parameter
const decodedNext = decodeURIComponent(next as string);
// Extract the query string after /o/authorize/
const match = decodedNext.match(/\/o\/authorize\/\?(.*)/);
if (match) {
const params = new URLSearchParams(match[1]);
const acrValues = params.get('acr_values');
const loginHint = params.get('login_hint');
if (acrValues && acrValues !== 'eidas1') {
setBlockingError(t('This authentication level is not supported.'));
} else {
setBlockingError('');
}
if (loginHint) {
setEmail(loginHint);
}
}
} catch (e) {
console.error('Error parsing next parameter:', e);
}
}
}, [next, t]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (blockingError) {
return;
}
fetchAPI('login/', {
method: 'POST',
body: JSON.stringify({ email, password }),
credentials: 'include', // Important for session cookie
})
.then((res) => {
if (!res.ok) {
setError(t('Login failed. Please try again.'));
} else {
if (next) {
window.location.href = next as string;
} else {
window.location.href = '/authorize/';
}
}
})
.catch(() => {
setError(t('Login failed. Please try again.'));
});
};
return (
<LoginForm
title={t('Sign in')}
labelEmail={t('Email')}
labelPassword={t('Password')}
labelSignIn={t('Sign in')}
email={email}
setEmail={setEmail}
setPassword={setPassword}
error={error}
handleSubmit={handleSubmit}
blockingError={blockingError}
/>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <LoginLayout>{page}</LoginLayout>;
};
export default Page;

View File

@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: desk
version: 0.0.3
version: 0.0.4

View File

@@ -74,6 +74,20 @@ spec:
serviceName: {{ include "desk.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
- path: /o
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "desk.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "desk.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
- path: /resource-server
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
@@ -124,6 +138,20 @@ spec:
serviceName: {{ include "desk.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
- path: /o
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "desk.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "desk.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
- path: /resource-server
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix