From fd8e0e08c33e10a3a335bb47285c461ba3a05a53 Mon Sep 17 00:00:00 2001 From: Quentin BEY Date: Mon, 10 Feb 2025 11:43:30 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84(oidc)=20add=20login=20page=20in=20?= =?UTF-8?q?the=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To have a better user experience, we want the login page to in the frontend. --- src/frontend/apps/desk/src/core/auth/Auth.tsx | 11 +- .../login/__tests__/InputUserEmail.test.tsx | 54 ++++++++ .../__tests__/InputUserPassword.test.tsx | 47 +++++++ .../features/login/__tests__/login.test.tsx | 117 ++++++++++++++++++ .../login/components/InputUserEmail.tsx | 25 ++++ .../login/components/InputUserPassword.tsx | 22 ++++ .../features/login/components/LoginForm.tsx | 95 ++++++++++++++ .../features/login/components/LoginLayout.tsx | 19 +++ .../src/features/login/components/index.ts | 4 + .../apps/desk/src/features/login/index.tsx | 1 + .../apps/desk/src/pages/login/index.tsx | 95 ++++++++++++++ src/helm/desk/Chart.yaml | 2 +- src/helm/desk/templates/ingress.yaml | 28 +++++ 13 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 src/frontend/apps/desk/src/features/login/__tests__/InputUserEmail.test.tsx create mode 100644 src/frontend/apps/desk/src/features/login/__tests__/InputUserPassword.test.tsx create mode 100644 src/frontend/apps/desk/src/features/login/__tests__/login.test.tsx create mode 100644 src/frontend/apps/desk/src/features/login/components/InputUserEmail.tsx create mode 100644 src/frontend/apps/desk/src/features/login/components/InputUserPassword.tsx create mode 100644 src/frontend/apps/desk/src/features/login/components/LoginForm.tsx create mode 100644 src/frontend/apps/desk/src/features/login/components/LoginLayout.tsx create mode 100644 src/frontend/apps/desk/src/features/login/components/index.ts create mode 100644 src/frontend/apps/desk/src/features/login/index.tsx create mode 100644 src/frontend/apps/desk/src/pages/login/index.tsx diff --git a/src/frontend/apps/desk/src/core/auth/Auth.tsx b/src/frontend/apps/desk/src/core/auth/Auth.tsx index 865a815..a0819f3 100644 --- a/src/frontend/apps/desk/src/core/auth/Auth.tsx +++ b/src/frontend/apps/desk/src/core/auth/Auth.tsx @@ -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 ( diff --git a/src/frontend/apps/desk/src/features/login/__tests__/InputUserEmail.test.tsx b/src/frontend/apps/desk/src/features/login/__tests__/InputUserEmail.test.tsx new file mode 100644 index 0000000..fd77ca9 --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/__tests__/InputUserEmail.test.tsx @@ -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(, { 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'); + }); +}); diff --git a/src/frontend/apps/desk/src/features/login/__tests__/InputUserPassword.test.tsx b/src/frontend/apps/desk/src/features/login/__tests__/InputUserPassword.test.tsx new file mode 100644 index 0000000..7fa4ffe --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/__tests__/InputUserPassword.test.tsx @@ -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(, { 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'); + }); +}); diff --git a/src/frontend/apps/desk/src/features/login/__tests__/login.test.tsx b/src/frontend/apps/desk/src/features/login/__tests__/login.test.tsx new file mode 100644 index 0000000..3cd9545 --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/__tests__/login.test.tsx @@ -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(, { 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(, { + 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(, { + wrapper: AppWrapper, + }); + + await waitFor(() => { + expect(screen.getByText(blockingError)).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/desk/src/features/login/components/InputUserEmail.tsx b/src/frontend/apps/desk/src/features/login/components/InputUserEmail.tsx new file mode 100644 index 0000000..0c00c14 --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/components/InputUserEmail.tsx @@ -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 ( + setEmail(e.target.value)} + required + fullWidth + autoComplete="username" + value={email} + /> + ); +}; diff --git a/src/frontend/apps/desk/src/features/login/components/InputUserPassword.tsx b/src/frontend/apps/desk/src/features/login/components/InputUserPassword.tsx new file mode 100644 index 0000000..c01b59d --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/components/InputUserPassword.tsx @@ -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 ( + setPassword(e.target.value)} + required + fullWidth + autoComplete="current-password" + /> + ); +}; diff --git a/src/frontend/apps/desk/src/features/login/components/LoginForm.tsx b/src/frontend/apps/desk/src/features/login/components/LoginForm.tsx new file mode 100644 index 0000000..37716c5 --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/components/LoginForm.tsx @@ -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 ( + + + + {title} + + + {!!blockingError ? ( + + {blockingError === 'loading' ? '' : blockingError} + + ) : ( +
+ + + + + + + + + {error && ( + + + {error} + + + )} + + + + +
+ )} +
{' '} +
+
+ ); +}; diff --git a/src/frontend/apps/desk/src/features/login/components/LoginLayout.tsx b/src/frontend/apps/desk/src/features/login/components/LoginLayout.tsx new file mode 100644 index 0000000..bee0627 --- /dev/null +++ b/src/frontend/apps/desk/src/features/login/components/LoginLayout.tsx @@ -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 ( + + +
+ + {children} + +