[frontend]: add login page

This commit is contained in:
Sergey Krylov 2025-08-02 15:39:28 +03:00
parent 4396438f5d
commit 857374be4e
10 changed files with 334 additions and 8 deletions

View File

@ -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",

View 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;

View File

@ -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>
);
} }

View 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;

View 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
View 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;
}
}

View File

@ -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) {

View File

@ -0,0 +1,10 @@
mutation LoginUser($data: LoginInput!) {
loginUser(data: $data) {
message
user {
id
name
email
}
}
}

View 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>

View File

@ -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"