Skip to content

Commit

Permalink
Merge pull request #191 from autentia/feature/new-login
Browse files Browse the repository at this point in the history
Feature/new login
  • Loading branch information
fjmpaez authored Apr 16, 2024
2 parents acd810e + f6cacc1 commit 172094b
Show file tree
Hide file tree
Showing 17 changed files with 254 additions and 11 deletions.
23 changes: 23 additions & 0 deletions src/features/auth/application/login-and-check-user.ts
Original file line number Diff line number Diff line change
@@ -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<User, UserCredentials> {
constructor(
@inject(AUTH_REPOSITORY) private authRepository: AuthRepository,
@inject(USER_REPOSITORY) private userRepository: UserRepository
) {
super()
}

async internalExecute(userCredentials: UserCredentials): Promise<User> {
await this.authRepository.login(userCredentials)
return this.userRepository.getUser()
}
}
21 changes: 21 additions & 0 deletions src/features/auth/application/login-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -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<AuthRepository>()
return {
userRepository,
loginCmd: new LoginCmd(userRepository)
}
}
17 changes: 17 additions & 0 deletions src/features/auth/application/login-cmd.ts
Original file line number Diff line number Diff line change
@@ -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<UserCredentials> {
constructor(@inject(AUTH_REPOSITORY) private userRepository: AuthRepository) {
super()
}

async internalExecute(userCredentials: UserCredentials): Promise<void> {
await this.userRepository.login(userCredentials)
}
}
4 changes: 4 additions & 0 deletions src/features/auth/domain/auth-repository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { UserCredentials } from './user-credentials'

export interface AuthRepository {
logout(): Promise<void>

login(credentials: UserCredentials): Promise<void>
}
4 changes: 4 additions & 0 deletions src/features/auth/domain/user-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface UserCredentials {
username: string
password: string
}
2 changes: 2 additions & 0 deletions src/features/auth/infrastructure/fake-auth-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ import { AuthRepository } from '../domain/auth-repository'
@singleton()
export class FakeAuthRepository implements AuthRepository {
async logout(): Promise<void> {}

async login(): Promise<void> {}
}
11 changes: 11 additions & 0 deletions src/features/auth/infrastructure/http-auth-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions src/features/auth/infrastructure/http-auth-repository.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return this.httpClient.post<void>(HttpAuthRepository.logoutPath)
}

async login({ username, password }: UserCredentials): Promise<void> {
return this.httpClient.post(HttpAuthRepository.loginPath, { username, password })
}
}
15 changes: 15 additions & 0 deletions src/features/auth/ui/components/login-form/login-form.schema.ts
Original file line number Diff line number Diff line change
@@ -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<LoginFormSchema> = yup
.object()
.shape({
username: yup.string().required(i18n.t('form_errors.field_required')),
password: yup.string().required(i18n.t('form_errors.field_required'))
})
.defined()
33 changes: 32 additions & 1 deletion src/features/auth/ui/components/login-form/login-form.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(<LoginForm />)
}
it('should show welcome title', async () => {
Expand All @@ -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 }
)
})
})
72 changes: 68 additions & 4 deletions src/features/auth/ui/components/login-form/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,90 @@
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<LoginFormSchema>({
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 (
<Flex direction="column" height="100%" bgColor={bgColor}>
<Flex direction="column" m="auto" textAlign="center" minWidth="300px">
<Flex direction="column" m="auto" minWidth="300px" textAlign="center">
<Logo size="lg" />
<Heading as="h1" size="xl">
{t('login_page.welcome_title')}
</Heading>
<Heading as="h2" size="md" mt={2} mb={8}>
{t('login_page.welcome_message')}
</Heading>
<SignInWithGoogleButton />

<Flex direction="column" textAlign={'start'} gap={8}>
{showLDAPLogin && (
<Stack
direction={'column'}
as="form"
id="login-form"
spacing={6}
onSubmit={handleSubmit(onSubmit)}
>
<Box>
<TextField
label={t('login_page.username_field')}
error={errors.username?.message}
{...register('username')}
/>
</Box>

<Box>
<PasswordField
label={t('login_page.password_field')}
error={errors.password?.message}
{...register('password')}
/>
</Box>
<SubmitButton formId="login-form">{t('login_page.login')}</SubmitButton>
</Stack>
)}

{showGoogleLogin && <SignInWithGoogleButton />}
</Flex>
</Flex>
<AppVersion />
</Flex>
Expand Down
1 change: 1 addition & 0 deletions src/shared/arch/hooks/use-execute-use-case-on-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function useExecuteUseCaseOnMount<Param, Result>(
useCase
.execute(param, options)
.then((response) => setResult(response))
.catch(() => setResult(undefined))
.finally(() => {
setIsLoading(false)
})
Expand Down
21 changes: 21 additions & 0 deletions src/shared/components/form-fields/password-field.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement, Props>((props, ref) => {
const id = props.name + '_field'

return (
<FormControl data-testid={id} id={id} isInvalid={props.error !== undefined}>
<FloatingLabelInput type="password" ref={ref} {...props} />
<FormErrorMessage>{props.error}</FormErrorMessage>
</FormControl>
)
})

PasswordField.displayName = 'PasswordField'
22 changes: 18 additions & 4 deletions src/shared/contexts/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ export type AuthState = {
setCanApproval?: Dispatch<SetStateAction<boolean>>
canBlock?: boolean
setCanBlock?: Dispatch<SetStateAction<boolean>>
checkLoggedUser?: (userLogged: User) => void
}

const AuthStateContext = createContext<AuthState>({})
Expand All @@ -32,17 +34,29 @@ export const AuthProvider: FC<PropsWithChildren<AuthState>> = (props) => {
const [canBlock, setCanBlock] = useState<boolean>(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 (
<AuthStateContext.Provider
value={{ isLoggedIn, setIsLoggedIn, canApproval, setCanApproval, canBlock, setCanBlock }}
value={{
isLoggedIn,
setIsLoggedIn,
canApproval,
setCanApproval,
canBlock,
setCanBlock,
checkLoggedUser
}}
>
{!isLoading && props.children}
</AuthStateContext.Provider>
Expand Down
1 change: 1 addition & 0 deletions src/shared/http/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BASE_URL } from '../api/url'
import { getParamsSerializer } from './get-params-serializer'

type DataType = Record<string, unknown>

interface QueryParams {
[key: string]: string | number | boolean | undefined
}
Expand Down
6 changes: 5 additions & 1 deletion src/shared/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/shared/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 172094b

Please sign in to comment.