diff --git a/src/features/auth/application/login-and-check-user.ts b/src/features/auth/application/login-and-check-user.ts new file mode 100644 index 000000000..9ba8bab38 --- /dev/null +++ b/src/features/auth/application/login-and-check-user.ts @@ -0,0 +1,23 @@ +import { inject, singleton } from 'tsyringe' +import { AUTH_REPOSITORY, USER_REPOSITORY } from '../../../shared/di/container-tokens' +import { Query, UseCaseKey } from '@archimedes/arch' +import { UserCredentials } from '../domain/user-credentials' +import { User } from '../../shared/user/domain/user' +import type { AuthRepository } from '../domain/auth-repository' +import type { UserRepository } from '../../shared/user/domain/user-repository' + +@UseCaseKey('LoginAndCheckUser') +@singleton() +export class LoginAndCheckUser extends Query { + constructor( + @inject(AUTH_REPOSITORY) private authRepository: AuthRepository, + @inject(USER_REPOSITORY) private userRepository: UserRepository + ) { + super() + } + + async internalExecute(userCredentials: UserCredentials): Promise { + await this.authRepository.login(userCredentials) + return this.userRepository.getUser() + } +} diff --git a/src/features/auth/application/login-cmd.test.ts b/src/features/auth/application/login-cmd.test.ts new file mode 100644 index 000000000..32027406d --- /dev/null +++ b/src/features/auth/application/login-cmd.test.ts @@ -0,0 +1,21 @@ +import { mock } from 'jest-mock-extended' +import { AuthRepository } from '../domain/auth-repository' +import { LoginCmd } from './login-cmd' + +describe('LoginCmd', () => { + it('should execute login using the repository', async () => { + const { userRepository, loginCmd } = setup() + + await loginCmd.internalExecute({ username: 'foo', password: 'password' }) + + expect(userRepository.login).toHaveBeenCalledWith({ username: 'foo', password: 'password' }) + }) +}) + +function setup() { + const userRepository = mock() + return { + userRepository, + loginCmd: new LoginCmd(userRepository) + } +} diff --git a/src/features/auth/application/login-cmd.ts b/src/features/auth/application/login-cmd.ts new file mode 100644 index 000000000..9c272a3b7 --- /dev/null +++ b/src/features/auth/application/login-cmd.ts @@ -0,0 +1,17 @@ +import { Command, UseCaseKey } from '@archimedes/arch' +import { inject, singleton } from 'tsyringe' +import { AUTH_REPOSITORY } from '../../../shared/di/container-tokens' +import type { AuthRepository } from '../domain/auth-repository' +import { UserCredentials } from '../domain/user-credentials' + +@UseCaseKey('LoginCmd') +@singleton() +export class LoginCmd extends Command { + constructor(@inject(AUTH_REPOSITORY) private userRepository: AuthRepository) { + super() + } + + async internalExecute(userCredentials: UserCredentials): Promise { + await this.userRepository.login(userCredentials) + } +} diff --git a/src/features/auth/domain/auth-repository.ts b/src/features/auth/domain/auth-repository.ts index 4a36e32c8..a5e0284d6 100644 --- a/src/features/auth/domain/auth-repository.ts +++ b/src/features/auth/domain/auth-repository.ts @@ -1,3 +1,7 @@ +import { UserCredentials } from './user-credentials' + export interface AuthRepository { logout(): Promise + + login(credentials: UserCredentials): Promise } diff --git a/src/features/auth/domain/user-credentials.ts b/src/features/auth/domain/user-credentials.ts new file mode 100644 index 000000000..5ae14cf8f --- /dev/null +++ b/src/features/auth/domain/user-credentials.ts @@ -0,0 +1,4 @@ +export interface UserCredentials { + username: string + password: string +} diff --git a/src/features/auth/infrastructure/fake-auth-repository.ts b/src/features/auth/infrastructure/fake-auth-repository.ts index e09ff8cb6..1633e09da 100644 --- a/src/features/auth/infrastructure/fake-auth-repository.ts +++ b/src/features/auth/infrastructure/fake-auth-repository.ts @@ -4,4 +4,6 @@ import { AuthRepository } from '../domain/auth-repository' @singleton() export class FakeAuthRepository implements AuthRepository { async logout(): Promise {} + + async login(): Promise {} } diff --git a/src/features/auth/infrastructure/http-auth-repository.test.ts b/src/features/auth/infrastructure/http-auth-repository.test.ts index 24593e4c2..6d1bf7a10 100644 --- a/src/features/auth/infrastructure/http-auth-repository.test.ts +++ b/src/features/auth/infrastructure/http-auth-repository.test.ts @@ -12,6 +12,17 @@ describe('HttpAuthRepository', () => { expect(httpClient.post).toHaveBeenCalledWith('/logout') }) + + test('should login', () => { + const { httpClient, authRepository } = setup() + + authRepository.login({ username: 'username', password: 'password' }) + + expect(httpClient.post).toHaveBeenCalledWith(`/login`, { + password: 'password', + username: 'username' + }) + }) }) function setup() { diff --git a/src/features/auth/infrastructure/http-auth-repository.ts b/src/features/auth/infrastructure/http-auth-repository.ts index bef0f8aa5..bf0e047d8 100644 --- a/src/features/auth/infrastructure/http-auth-repository.ts +++ b/src/features/auth/infrastructure/http-auth-repository.ts @@ -1,14 +1,20 @@ import { HttpClient } from '../../../shared/http/http-client' import { singleton } from 'tsyringe' import { AuthRepository } from '../domain/auth-repository' +import { UserCredentials } from '../domain/user-credentials' @singleton() export class HttpAuthRepository implements AuthRepository { protected static logoutPath = '/logout' + protected static loginPath = '/login' constructor(private httpClient: HttpClient) {} async logout(): Promise { return this.httpClient.post(HttpAuthRepository.logoutPath) } + + async login({ username, password }: UserCredentials): Promise { + return this.httpClient.post(HttpAuthRepository.loginPath, { username, password }) + } } diff --git a/src/features/auth/ui/components/login-form/login-form.schema.ts b/src/features/auth/ui/components/login-form/login-form.schema.ts new file mode 100644 index 000000000..101e9dce0 --- /dev/null +++ b/src/features/auth/ui/components/login-form/login-form.schema.ts @@ -0,0 +1,15 @@ +import * as yup from 'yup' +import { i18n } from '../../../../../shared/i18n/i18n' + +export interface LoginFormSchema { + username: string + password: string +} + +export const loginFormSchema: yup.ObjectSchema = yup + .object() + .shape({ + username: yup.string().required(i18n.t('form_errors.field_required')), + password: yup.string().required(i18n.t('form_errors.field_required')) + }) + .defined() diff --git a/src/features/auth/ui/components/login-form/login-form.test.tsx b/src/features/auth/ui/components/login-form/login-form.test.tsx index a7afc545f..60a2d03ef 100644 --- a/src/features/auth/ui/components/login-form/login-form.test.tsx +++ b/src/features/auth/ui/components/login-form/login-form.test.tsx @@ -1,5 +1,8 @@ -import { render, screen, waitFor } from '../../../../../test-utils/render' +import { render, screen, userEvent, waitFor } from '../../../../../test-utils/render' import { LoginForm } from './login-form' +import { act } from '@testing-library/react' +import { useGetUseCase } from '../../../../../shared/arch/hooks/use-get-use-case' +import { useAuthContext } from '../../../../../shared/contexts/auth-context' jest.mock('../../../../version/ui/components/app-version', () => { return { @@ -8,8 +11,19 @@ jest.mock('../../../../version/ui/components/app-version', () => { } }) +jest.mock('../../../../../shared/arch/hooks/use-get-use-case') +jest.mock('../../../../../shared/contexts/auth-context') + +const useCaseSpy = jest.fn() + describe('LoginForm', () => { const setup = () => { + ;(useGetUseCase as jest.Mock).mockImplementation(() => ({ + isLoading: false, + useCase: { execute: useCaseSpy } + })) + ;(useAuthContext as jest.Mock).mockImplementation(() => ({ checkLoggedUser: jest.fn() })) + render() } it('should show welcome title', async () => { @@ -24,4 +38,21 @@ describe('LoginForm', () => { expect(screen.getByText('login_page.welcome_message')).toBeInTheDocument() }) }) + it('should login', async () => { + setup() + + await act(async () => { + await userEvent.type(screen.getByLabelText('login_page.username_field'), 'user') + await userEvent.type(screen.getByLabelText('login_page.password_field'), 'password') + await userEvent.click(screen.getByRole('button', { name: 'login_page.login' })) + }) + + expect(useCaseSpy).toHaveBeenCalledWith( + { + password: 'password', + username: 'user' + }, + { errorMessage: 'login_page.invalid_credentials', showToastError: true } + ) + }) }) diff --git a/src/features/auth/ui/components/login-form/login-form.tsx b/src/features/auth/ui/components/login-form/login-form.tsx index ad34f6b69..58d6b9e86 100644 --- a/src/features/auth/ui/components/login-form/login-form.tsx +++ b/src/features/auth/ui/components/login-form/login-form.tsx @@ -1,18 +1,52 @@ -import { Flex, Heading, useColorModeValue } from '@chakra-ui/react' +import { Box, Flex, Heading, Stack, useColorModeValue } from '@chakra-ui/react' import { AppVersion } from '../../../../version/ui/components/app-version' import { useTranslation } from 'react-i18next' import { Logo } from '../../../../../shared/components/logo' -import { SignInWithGoogleButton } from '../sign-in-with-google/sign-in-with-google-button' import { FC } from 'react' +import { useForm } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import { LoginFormSchema, loginFormSchema } from './login-form.schema' +import { TextField } from '../../../../../shared/components/form-fields/text-field' +import { SubmitButton } from '../../../../../shared/components/form-fields/submit-button' +import { useGetUseCase } from '../../../../../shared/arch/hooks/use-get-use-case' +import { SignInWithGoogleButton } from '../sign-in-with-google/sign-in-with-google-button' +import { useAuthContext } from '../../../../../shared/contexts/auth-context' +import { PasswordField } from '../../../../../shared/components/form-fields/password-field' +import { LoginAndCheckUser } from '../../../application/login-and-check-user' export const LoginForm: FC = () => { const { t } = useTranslation() + const { checkLoggedUser } = useAuthContext() const bgColor = useColorModeValue('white', undefined) + const { useCase: loginAndCheckUser } = useGetUseCase(LoginAndCheckUser) + + const showLDAPLogin = true + const showGoogleLogin = false + + const { + handleSubmit, + register, + formState: { errors } + } = useForm({ + defaultValues: { username: '', password: '' }, + resolver: yupResolver(loginFormSchema), + mode: 'onSubmit' + }) + + const onSubmit = async (data: LoginFormSchema) => { + const response = await loginAndCheckUser.execute(data, { + showToastError: true, + errorMessage: t('login_page.invalid_credentials') + }) + if (response) { + checkLoggedUser!(response) + } + } return ( - + {t('login_page.welcome_title')} @@ -20,7 +54,37 @@ export const LoginForm: FC = () => { {t('login_page.welcome_message')} - + + + {showLDAPLogin && ( + + + + + + + + + {t('login_page.login')} + + )} + + {showGoogleLogin && } + diff --git a/src/shared/arch/hooks/use-execute-use-case-on-mount.ts b/src/shared/arch/hooks/use-execute-use-case-on-mount.ts index 714ac9615..073f6b395 100644 --- a/src/shared/arch/hooks/use-execute-use-case-on-mount.ts +++ b/src/shared/arch/hooks/use-execute-use-case-on-mount.ts @@ -21,6 +21,7 @@ export function useExecuteUseCaseOnMount( useCase .execute(param, options) .then((response) => setResult(response)) + .catch(() => setResult(undefined)) .finally(() => { setIsLoading(false) }) diff --git a/src/shared/components/form-fields/password-field.tsx b/src/shared/components/form-fields/password-field.tsx new file mode 100644 index 000000000..cbbd1db2c --- /dev/null +++ b/src/shared/components/form-fields/password-field.tsx @@ -0,0 +1,21 @@ +import { FormControl, FormErrorMessage, InputProps } from '@chakra-ui/react' +import { forwardRef } from 'react' +import { FloatingLabelInput } from '../floating-label-input' + +interface Props extends InputProps { + label: string + error?: string +} + +export const PasswordField = forwardRef((props, ref) => { + const id = props.name + '_field' + + return ( + + + {props.error} + + ) +}) + +PasswordField.displayName = 'PasswordField' diff --git a/src/shared/contexts/auth-context.tsx b/src/shared/contexts/auth-context.tsx index 7f8be56ed..8403809f8 100644 --- a/src/shared/contexts/auth-context.tsx +++ b/src/shared/contexts/auth-context.tsx @@ -10,6 +10,7 @@ import { useState } from 'react' import { useExecuteUseCaseOnMount } from '../arch/hooks/use-execute-use-case-on-mount' +import { User } from '../../features/shared/user/domain/user' export type AuthState = { isLoggedIn?: boolean @@ -18,6 +19,7 @@ export type AuthState = { setCanApproval?: Dispatch> canBlock?: boolean setCanBlock?: Dispatch> + checkLoggedUser?: (userLogged: User) => void } const AuthStateContext = createContext({}) @@ -32,17 +34,29 @@ export const AuthProvider: FC> = (props) => { const [canBlock, setCanBlock] = useState(false) const { isLoading, result: userLogged } = useExecuteUseCaseOnMount(GetUserLoggedQry) + const checkLoggedUser = (userLogged: User | undefined) => { + setIsLoggedIn(Boolean(userLogged)) + if (userLogged?.roles?.includes(APPROVAL_ROLE)) setCanApproval(true) + if (userLogged?.roles?.includes(PROJECT_BLOCKER)) setCanBlock(true) + } + useLayoutEffect(() => { if (!isLoading) { - setIsLoggedIn(Boolean(userLogged)) - if (userLogged?.roles?.includes(APPROVAL_ROLE)) setCanApproval(true) - if (userLogged?.roles?.includes(PROJECT_BLOCKER)) setCanBlock(true) + checkLoggedUser(userLogged) } }, [isLoading, userLogged]) return ( {!isLoading && props.children} diff --git a/src/shared/http/http-client.ts b/src/shared/http/http-client.ts index 68fcec146..9f16f60e3 100644 --- a/src/shared/http/http-client.ts +++ b/src/shared/http/http-client.ts @@ -5,6 +5,7 @@ import { BASE_URL } from '../api/url' import { getParamsSerializer } from './get-params-serializer' type DataType = Record + interface QueryParams { [key: string]: string | number | boolean | undefined } diff --git a/src/shared/i18n/en.json b/src/shared/i18n/en.json index a16e1ee81..29e245586 100644 --- a/src/shared/i18n/en.json +++ b/src/shared/i18n/en.json @@ -45,7 +45,11 @@ "login_page": { "welcome_title": "Welcome", "welcome_message": "Sign in with your company account", - "sign_in_with_google": "Sign in with Google" + "sign_in_with_google": "Sign in with Google", + "username_field": "Username", + "password_field": "Password", + "login": "Login", + "invalid_credentials": "Incorrect username or password." }, "activity_form": { "employee": "Employee", diff --git a/src/shared/i18n/es.json b/src/shared/i18n/es.json index d4c6a3122..af6a252e6 100644 --- a/src/shared/i18n/es.json +++ b/src/shared/i18n/es.json @@ -45,7 +45,11 @@ "login_page": { "welcome_title": "Bienvenido", "welcome_message": "Inicia sesión con tu cuenta de empresa", - "sign_in_with_google": "Iniciar sesión con Google" + "sign_in_with_google": "Iniciar sesión con Google", + "username_field": "Usuario", + "password_field": "Contraseña", + "login": "Iniciar sesión", + "invalid_credentials": "Usuario o contraseña incorrectos." }, "activity_form": { "employee": "Empleado",