💄(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:
@@ -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 />
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './LoginLayout';
|
||||
export * from './LoginForm';
|
||||
export * from './InputUserEmail';
|
||||
export * from './InputUserPassword';
|
||||
1
src/frontend/apps/desk/src/features/login/index.tsx
Normal file
1
src/frontend/apps/desk/src/features/login/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components';
|
||||
95
src/frontend/apps/desk/src/pages/login/index.tsx
Normal file
95
src/frontend/apps/desk/src/pages/login/index.tsx
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: desk
|
||||
version: 0.0.3
|
||||
version: 0.0.4
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user