[frontend]: add login page
This commit is contained in:
parent
4396438f5d
commit
857374be4e
@ -21,6 +21,7 @@
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"graphql": "16.11.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "0.534.0",
|
||||
"next": "15.4.5",
|
||||
"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'
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('home')
|
||||
|
||||
return (
|
||||
<div>
|
||||
{t('title')}
|
||||
</div>
|
||||
);
|
||||
redirect('account/login');
|
||||
}
|
||||
|
||||
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 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<{
|
||||
data: VerificationInput;
|
||||
}>;
|
||||
@ -648,6 +655,44 @@ export function useCreateUserMutation(baseOptions?: Apollo.MutationHookOptions<C
|
||||
export type CreateUserMutationHookResult = ReturnType<typeof useCreateUserMutation>;
|
||||
export type CreateUserMutationResult = Apollo.MutationResult<CreateUserMutation>;
|
||||
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`
|
||||
mutation VerifyAccount($data: VerificationInput!) {
|
||||
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"
|
||||
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:
|
||||
version "8.2.6"
|
||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user