[frontend]: add login page
This commit is contained in:
parent
4396438f5d
commit
857374be4e
@ -21,6 +21,7 @@
|
|||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"graphql": "16.11.0",
|
"graphql": "16.11.0",
|
||||||
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "0.534.0",
|
"lucide-react": "0.534.0",
|
||||||
"next": "15.4.5",
|
"next": "15.4.5",
|
||||||
"next-intl": "4.3.4",
|
"next-intl": "4.3.4",
|
||||||
|
|||||||
20
frontend/src/app/account/login/page.tsx
Normal file
20
frontend/src/app/account/login/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import LoginAccountForm from '@/components/features/auth/forms/LoginAccountForm';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('auth.login');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginAccountPage = () => {
|
||||||
|
return (
|
||||||
|
<LoginAccountForm/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginAccountPage;
|
||||||
@ -1,13 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const t = useTranslations('home')
|
redirect('account/login');
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{t('title')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
152
frontend/src/components/features/auth/forms/LoginAccountForm.tsx
Normal file
152
frontend/src/components/features/auth/forms/LoginAccountForm.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { useLoginUserMutation } from '@/graphql/generated/output';
|
||||||
|
import { loginSchema, TypeLoginSchema } from '@/schemas/auth/login.schema';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import AuthWrapper from '../AuthWrapper';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/common/InputOTP';
|
||||||
|
|
||||||
|
const LoginAccountForm = () => {
|
||||||
|
const t = useTranslations('auth.login')
|
||||||
|
const [isShowTwoFactor, setIsShowTwoFactor] = useState(false)
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const form = useForm<TypeLoginSchema>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
login: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { isValid } = form.formState
|
||||||
|
|
||||||
|
const [login, {loading: isLoadingLogin}] = useLoginUserMutation({
|
||||||
|
onCompleted(data) {
|
||||||
|
if (data.loginUser.message) {
|
||||||
|
setIsShowTwoFactor(true)
|
||||||
|
} else {
|
||||||
|
toast.success(t('successMessage'))
|
||||||
|
router.push('/dashboard/settings')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('errorMessage'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(data: TypeLoginSchema) {
|
||||||
|
void login({variables: {data}})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthWrapper
|
||||||
|
heading={t('heading')}
|
||||||
|
backButtonLabel={t('backButtonLabel')}
|
||||||
|
backButtonHref='/account/create'
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className='grid gap-y-3'
|
||||||
|
>
|
||||||
|
{isShowTwoFactor ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='pin'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('pinLabel')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP maxLength={6} {...field}>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t('pinDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='login'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('loginLabel')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='johndoe'
|
||||||
|
disabled={isLoadingLogin}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t('loginDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<FormLabel>
|
||||||
|
{t('passwordLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
<Link
|
||||||
|
href='/account/recovery'
|
||||||
|
className='ml-auto inline-block text-sm'
|
||||||
|
>
|
||||||
|
{t('forgotPassword')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='********'
|
||||||
|
type='password'
|
||||||
|
disabled={isLoadingLogin}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t('passwordDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className='mt-2 w-full'
|
||||||
|
disabled={!isValid || isLoadingLogin}
|
||||||
|
>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</AuthWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginAccountForm;
|
||||||
80
frontend/src/components/ui/common/InputOTP.tsx
Normal file
80
frontend/src/components/ui/common/InputOTP.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/twMerge';
|
||||||
|
import { OTPInput, OTPInputContext } from 'input-otp'
|
||||||
|
import { Dot } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ComponentRef,
|
||||||
|
forwardRef,
|
||||||
|
useContext
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
const InputOTP = forwardRef<
|
||||||
|
ComponentRef<typeof OTPInput>,
|
||||||
|
ComponentPropsWithoutRef<typeof OTPInput>
|
||||||
|
>(({ className, containerClassName, ...props }, ref) => (
|
||||||
|
<OTPInput
|
||||||
|
ref={ref}
|
||||||
|
containerClassName={cn(
|
||||||
|
'flex items-center gap-2 has-disabled:opacity-50',
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn('disabled:cursor-not-allowed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
InputOTP.displayName = 'InputOTP'
|
||||||
|
|
||||||
|
const InputOTPGroup = forwardRef<
|
||||||
|
ComponentRef<'div'>,
|
||||||
|
ComponentPropsWithoutRef<'div'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center gap-x-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
InputOTPGroup.displayName = 'InputOTPGroup'
|
||||||
|
|
||||||
|
const InputOTPSlot = forwardRef<
|
||||||
|
ComponentRef<'div'>,
|
||||||
|
ComponentPropsWithoutRef<'div'> & { index: number }
|
||||||
|
>(({ index, className, ...props }, ref) => {
|
||||||
|
const inputOTPContext = useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-10 w-14 items-center justify-center rounded-md border border-border text-sm transition-all',
|
||||||
|
isActive && 'z-10 ring-2 ring-primary ring-offset-background',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
|
||||||
|
<div className='animate-caret-blink h-4 w-px bg-foreground duration-1000' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
InputOTPSlot.displayName = 'InputOTPSlot'
|
||||||
|
|
||||||
|
const InputOTPSeparator = forwardRef<
|
||||||
|
ComponentRef<'div'>,
|
||||||
|
ComponentPropsWithoutRef<'div'>
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<div ref={ref} role='separator' {...props}>
|
||||||
|
<Dot />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
InputOTPSeparator.displayName = 'InputOTPSeparator'
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot }
|
||||||
10
frontend/src/global.d.ts
vendored
Normal file
10
frontend/src/global.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import messages from '../public/languages/ru.json';
|
||||||
|
|
||||||
|
const locales = ['ru', 'en'] as const;
|
||||||
|
|
||||||
|
declare module 'next-intl' {
|
||||||
|
interface AppConfig {
|
||||||
|
Locale: (typeof locales)[number];
|
||||||
|
Messages: typeof messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -598,6 +598,13 @@ export type CreateUserMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type CreateUserMutation = { __typename?: 'Mutation', createUser: { __typename?: 'UserModel', name: string, password: string, email: string } };
|
export type CreateUserMutation = { __typename?: 'Mutation', createUser: { __typename?: 'UserModel', name: string, password: string, email: string } };
|
||||||
|
|
||||||
|
export type LoginUserMutationVariables = Exact<{
|
||||||
|
data: LoginInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type LoginUserMutation = { __typename?: 'Mutation', loginUser: { __typename?: 'AuthModel', message?: string | null, user?: { __typename?: 'UserModel', id: string, name: string, email: string } | null } };
|
||||||
|
|
||||||
export type VerifyAccountMutationVariables = Exact<{
|
export type VerifyAccountMutationVariables = Exact<{
|
||||||
data: VerificationInput;
|
data: VerificationInput;
|
||||||
}>;
|
}>;
|
||||||
@ -648,6 +655,44 @@ export function useCreateUserMutation(baseOptions?: Apollo.MutationHookOptions<C
|
|||||||
export type CreateUserMutationHookResult = ReturnType<typeof useCreateUserMutation>;
|
export type CreateUserMutationHookResult = ReturnType<typeof useCreateUserMutation>;
|
||||||
export type CreateUserMutationResult = Apollo.MutationResult<CreateUserMutation>;
|
export type CreateUserMutationResult = Apollo.MutationResult<CreateUserMutation>;
|
||||||
export type CreateUserMutationOptions = Apollo.BaseMutationOptions<CreateUserMutation, CreateUserMutationVariables>;
|
export type CreateUserMutationOptions = Apollo.BaseMutationOptions<CreateUserMutation, CreateUserMutationVariables>;
|
||||||
|
export const LoginUserDocument = gql`
|
||||||
|
mutation LoginUser($data: LoginInput!) {
|
||||||
|
loginUser(data: $data) {
|
||||||
|
message
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type LoginUserMutationFn = Apollo.MutationFunction<LoginUserMutation, LoginUserMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useLoginUserMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useLoginUserMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useLoginUserMutation` returns a tuple that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - An object with fields that represent the current status of the mutation's execution
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [loginUserMutation, { data, loading, error }] = useLoginUserMutation({
|
||||||
|
* variables: {
|
||||||
|
* data: // value for 'data'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useLoginUserMutation(baseOptions?: Apollo.MutationHookOptions<LoginUserMutation, LoginUserMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<LoginUserMutation, LoginUserMutationVariables>(LoginUserDocument, options);
|
||||||
|
}
|
||||||
|
export type LoginUserMutationHookResult = ReturnType<typeof useLoginUserMutation>;
|
||||||
|
export type LoginUserMutationResult = Apollo.MutationResult<LoginUserMutation>;
|
||||||
|
export type LoginUserMutationOptions = Apollo.BaseMutationOptions<LoginUserMutation, LoginUserMutationVariables>;
|
||||||
export const VerifyAccountDocument = gql`
|
export const VerifyAccountDocument = gql`
|
||||||
mutation VerifyAccount($data: VerificationInput!) {
|
mutation VerifyAccount($data: VerificationInput!) {
|
||||||
verifyAccount(data: $data) {
|
verifyAccount(data: $data) {
|
||||||
|
|||||||
10
frontend/src/graphql/metations/auth/LoginUser.graphql
Normal file
10
frontend/src/graphql/metations/auth/LoginUser.graphql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
mutation LoginUser($data: LoginInput!) {
|
||||||
|
loginUser(data: $data) {
|
||||||
|
message
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/schemas/auth/login.schema.ts
Normal file
9
frontend/src/schemas/auth/login.schema.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
login: z.string().min(1),
|
||||||
|
password: z.string().min(8),
|
||||||
|
pin: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TypeLoginSchema = z.infer<typeof loginSchema>
|
||||||
@ -3563,6 +3563,11 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
input-otp@1.4.2:
|
||||||
|
version "1.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.2.tgz#f4d3d587d0f641729e55029b3b8c4870847f4f07"
|
||||||
|
integrity sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==
|
||||||
|
|
||||||
inquirer@^8.0.0:
|
inquirer@^8.0.0:
|
||||||
version "8.2.6"
|
version "8.2.6"
|
||||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562"
|
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user