From 857374be4ecdd82de053d23fa0ef613320ea15fd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 2 Aug 2025 15:39:28 +0300 Subject: [PATCH] [frontend]: add login page --- frontend/package.json | 1 + frontend/src/app/account/login/page.tsx | 20 +++ frontend/src/app/page.tsx | 10 +- .../features/auth/forms/LoginAccountForm.tsx | 152 ++++++++++++++++++ .../src/components/ui/common/InputOTP.tsx | 80 +++++++++ frontend/src/global.d.ts | 10 ++ frontend/src/graphql/generated/output.ts | 45 ++++++ .../graphql/metations/auth/LoginUser.graphql | 10 ++ frontend/src/schemas/auth/login.schema.ts | 9 ++ frontend/yarn.lock | 5 + 10 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 frontend/src/app/account/login/page.tsx create mode 100644 frontend/src/components/features/auth/forms/LoginAccountForm.tsx create mode 100644 frontend/src/components/ui/common/InputOTP.tsx create mode 100644 frontend/src/global.d.ts create mode 100644 frontend/src/graphql/metations/auth/LoginUser.graphql create mode 100644 frontend/src/schemas/auth/login.schema.ts diff --git a/frontend/package.json b/frontend/package.json index 9851d32..b71cc4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/account/login/page.tsx b/frontend/src/app/account/login/page.tsx new file mode 100644 index 0000000..b0e3a4c --- /dev/null +++ b/frontend/src/app/account/login/page.tsx @@ -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 { + const t = await getTranslations('auth.login'); + + return { + title: t('heading') + } +} + +const LoginAccountPage = () => { + return ( + + ); +}; + +export default LoginAccountPage; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 079b308..4ff237b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 ( -
- {t('title')} -
- ); + redirect('account/login'); } diff --git a/frontend/src/components/features/auth/forms/LoginAccountForm.tsx b/frontend/src/components/features/auth/forms/LoginAccountForm.tsx new file mode 100644 index 0000000..ff8400f --- /dev/null +++ b/frontend/src/components/features/auth/forms/LoginAccountForm.tsx @@ -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({ + 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 ( + +
+ + {isShowTwoFactor ? ( + ( + + {t('pinLabel')} + + + + + + + + + + + + + + {t('pinDescription')} + + + )} + /> + ) : ( + <> + ( + + {t('loginLabel')} + + + + + {t('loginDescription')} + + + )} + /> + ( + +
+ + {t('passwordLabel')} + + + {t('forgotPassword')} + +
+ + + + + {t('passwordDescription')} + +
+ )} + /> + + )} + + + +
+ ); +}; + +export default LoginAccountForm; diff --git a/frontend/src/components/ui/common/InputOTP.tsx b/frontend/src/components/ui/common/InputOTP.tsx new file mode 100644 index 0000000..c6bb64d --- /dev/null +++ b/frontend/src/components/ui/common/InputOTP.tsx @@ -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, + ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = 'InputOTP' + +const InputOTPGroup = forwardRef< + ComponentRef<'div'>, + ComponentPropsWithoutRef<'div'> +>(({ className, ...props }, ref) => ( +
+)) +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 ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = 'InputOTPSlot' + +const InputOTPSeparator = forwardRef< + ComponentRef<'div'>, + ComponentPropsWithoutRef<'div'> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = 'InputOTPSeparator' + +export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts new file mode 100644 index 0000000..307a5b9 --- /dev/null +++ b/frontend/src/global.d.ts @@ -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; + } +} diff --git a/frontend/src/graphql/generated/output.ts b/frontend/src/graphql/generated/output.ts index b552f72..23bee96 100644 --- a/frontend/src/graphql/generated/output.ts +++ b/frontend/src/graphql/generated/output.ts @@ -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; export type CreateUserMutationResult = Apollo.MutationResult; export type CreateUserMutationOptions = Apollo.BaseMutationOptions; +export const LoginUserDocument = gql` + mutation LoginUser($data: LoginInput!) { + loginUser(data: $data) { + message + user { + id + name + email + } + } +} + `; +export type LoginUserMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(LoginUserDocument, options); + } +export type LoginUserMutationHookResult = ReturnType; +export type LoginUserMutationResult = Apollo.MutationResult; +export type LoginUserMutationOptions = Apollo.BaseMutationOptions; export const VerifyAccountDocument = gql` mutation VerifyAccount($data: VerificationInput!) { verifyAccount(data: $data) { diff --git a/frontend/src/graphql/metations/auth/LoginUser.graphql b/frontend/src/graphql/metations/auth/LoginUser.graphql new file mode 100644 index 0000000..5bfe6de --- /dev/null +++ b/frontend/src/graphql/metations/auth/LoginUser.graphql @@ -0,0 +1,10 @@ +mutation LoginUser($data: LoginInput!) { + loginUser(data: $data) { + message + user { + id + name + email + } + } +} diff --git a/frontend/src/schemas/auth/login.schema.ts b/frontend/src/schemas/auth/login.schema.ts new file mode 100644 index 0000000..7ac234d --- /dev/null +++ b/frontend/src/schemas/auth/login.schema.ts @@ -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 diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 19a617c..85e6310 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"