From 8860f2bdf890f450faf1abcd761fdef0ccca0fd6 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 3 Dec 2024 21:34:24 +0100 Subject: [PATCH] feat(frontend): Add forgot password view --- apps/backend/src/app.module.ts | 10 +- apps/frontend/src/app/global.css | 4 + apps/frontend/src/plugins/core/langs/en.json | 12 ++ packages/frontend/package.json | 1 + .../frontend/src/components/ui/input-otp.tsx | 63 ++++++++++ .../frontend/src/components/ui/progress.tsx | 3 +- .../forgot_password/forgot_passowrd-view.tsx | 26 ++++ .../views/auth/sign/forgot_password/form.tsx | 48 ++++++++ .../hooks/use-forgot-password-view.ts | 16 +++ .../reset/hooks/use-reset-password.ts | 13 ++ .../forgot_password/reset/reset-password.tsx | 115 ++++++++++++++++++ .../theme/views/auth/sign/in/sign-in-view.tsx | 9 +- .../src/views/theme/views/dynamic-view.tsx | 19 ++- pnpm-lock.yaml | 14 +++ 14 files changed, 343 insertions(+), 10 deletions(-) create mode 100644 packages/frontend/src/components/ui/input-otp.tsx create mode 100644 packages/frontend/src/views/theme/views/auth/sign/forgot_password/forgot_passowrd-view.tsx create mode 100644 packages/frontend/src/views/theme/views/auth/sign/forgot_password/form.tsx create mode 100644 packages/frontend/src/views/theme/views/auth/sign/forgot_password/hooks/use-forgot-password-view.ts create mode 100644 packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/hooks/use-reset-password.ts create mode 100644 packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/reset-password.tsx diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b0274e6dc..0175cdb08 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; +import { emailResend } from 'vitnode-backend-email-resend'; import { VitNodeCoreModule } from 'vitnode-backend/app.module'; -// import { emailResend } from 'vitnode-backend-email-resend'; // import { emailSMTP } from 'vitnode-backend-email-smtp'; // import { aiGoogle } from 'vitnode-backend-ai-google'; // import { aiOpenAi } from 'vitnode-backend-ai-open-ai'; @@ -74,10 +74,10 @@ import { PluginsModule } from './plugins/plugins.module'; }, }, ], - // email: emailResend({ - // api_key: process.env.EMAIL_RESEND_API_KEY, - // from: process.env.EMAIL_RESEND_FROM, - // }), + email: emailResend({ + api_key: process.env.EMAIL_RESEND_API_KEY, + from: process.env.EMAIL_RESEND_FROM, + }), // email: emailSMTP({ // host: process.env.EMAIL_SMTP_HOST, // port: process.env.EMAIL_SMTP_PORT, diff --git a/apps/frontend/src/app/global.css b/apps/frontend/src/app/global.css index 89a696667..a9f1b56ee 100644 --- a/apps/frontend/src/app/global.css +++ b/apps/frontend/src/app/global.css @@ -11,6 +11,10 @@ @apply bg-background text-foreground; } + a { + @apply text-primary; + } + :root { --background: 220 40% 98%; --foreground: 213deg 5% 8%; diff --git a/apps/frontend/src/plugins/core/langs/en.json b/apps/frontend/src/plugins/core/langs/en.json index 7a7f3089b..d15b46c45 100644 --- a/apps/frontend/src/plugins/core/langs/en.json +++ b/apps/frontend/src/plugins/core/langs/en.json @@ -248,6 +248,18 @@ "not_verified": { "title": "Email is not verified", "desc": "Please check your inbox and click the link to activate your account." + }, + "forgot_password": { + "title": "Forgot Password?", + "desc": "Don't worry! We'll help you reset your password.", + "email": "Email", + "go_back_login": "Go back to login", + "pin": { + "label": "Pin", + "desc": "Enter the pin code you received in your email." + }, + "send_code": "Send code", + "change_password": "Change Password" } }, "sign_up": { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 77167fc8e..edf8d7c96 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -168,6 +168,7 @@ "embla-carousel-react": "^8.5.1", "emoji-mart": "^5.6.0", "html-react-parser": "^5.1.18", + "input-otp": "^1.4.1", "lodash": "^4.17.21", "lowlight": "^3.2.0", "next": "^15.0.3", diff --git a/packages/frontend/src/components/ui/input-otp.tsx b/packages/frontend/src/components/ui/input-otp.tsx new file mode 100644 index 000000000..85d52a747 --- /dev/null +++ b/packages/frontend/src/components/ui/input-otp.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { cn } from '@/helpers/classnames'; +import { OTPInput, OTPInputContext } from 'input-otp'; +import { Dot } from 'lucide-react'; +import React from 'react'; + +const InputOTP = ({ + className, + containerClassName, + ...props +}: React.ComponentProps) => ( + +); + +const InputOTPGroup = ({ + className, + ...props +}: React.ComponentProps<'div'>) => ( +
+); + +const InputOTPSlot = ({ + index, + className, + ...props +}: React.ComponentProps<'div'> & { index: number }) => { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +}; + +const InputOTPSeparator = ({ ...props }: React.ComponentProps<'div'>) => ( +
+ +
+); + +export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot }; diff --git a/packages/frontend/src/components/ui/progress.tsx b/packages/frontend/src/components/ui/progress.tsx index 530176ab1..b4d535433 100644 --- a/packages/frontend/src/components/ui/progress.tsx +++ b/packages/frontend/src/components/ui/progress.tsx @@ -1,7 +1,6 @@ 'use client'; import * as ProgressPrimitive from '@radix-ui/react-progress'; -import { ComponentProps } from 'react'; import { cn } from '../../helpers/classnames'; @@ -9,7 +8,7 @@ const Progress = ({ className, value, ...props -}: ComponentProps) => ( +}: React.ComponentProps) => ( { + const t = await getTranslations('core.sign_in.forgot_password'); + + return ( + +
+
+

+ {t('title')} +

+ {t('desc')} +
+ + +
+
+ ); +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/forgot_password/form.tsx b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/form.tsx new file mode 100644 index 000000000..26a0ca2c1 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/form.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { AutoForm } from '@/components/form/auto-form'; +import { AutoFormInput } from '@/components/form/fields/input'; +import { Button } from '@/components/ui/button'; +import { Link } from '@/navigation'; +import { ChevronLeftIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +import { useForgotPasswordView } from './hooks/use-forgot-password-view'; +import { ResetPassword } from './reset/reset-password'; + +export const FormForgotPassword = () => { + const t = useTranslations('core.sign_in.forgot_password'); + const { formSchema, email, onSubmit } = useForgotPasswordView(); + + if (email) { + return ; + } + + return ( + ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButton={props => ( + <> + + + + )} + /> + ); +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/forgot_password/hooks/use-forgot-password-view.ts b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/hooks/use-forgot-password-view.ts new file mode 100644 index 000000000..0b4b3a9a2 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/hooks/use-forgot-password-view.ts @@ -0,0 +1,16 @@ +import React from 'react'; +import { z } from 'zod'; + +export const useForgotPasswordView = () => { + const [email, setEmail] = React.useState(''); + + const formSchema = z.object({ + email: z.string().email().default(''), + }); + + const onSubmit = (values: z.infer) => { + setEmail(values.email); + }; + + return { formSchema, email, onSubmit }; +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/hooks/use-reset-password.ts b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/hooks/use-reset-password.ts new file mode 100644 index 000000000..7ae464db0 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/hooks/use-reset-password.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const useResetPassword = () => { + const formSchema = z.object({ + pin: z.string().min(6).max(6).default(''), + password: z + .string() + .regex(/^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()\-_=+{};:,<.>]).{8,}$/) + .default(''), + }); + + return { formSchema }; +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/reset-password.tsx b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/reset-password.tsx new file mode 100644 index 000000000..6dc0ec6b9 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/forgot_password/reset/reset-password.tsx @@ -0,0 +1,115 @@ +import { AutoForm } from '@/components/form/auto-form'; +import { AutoFormTooltip } from '@/components/form/fields/common/tooltip'; +import { AutoFormInput } from '@/components/form/fields/input'; +import { Button } from '@/components/ui/button'; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { CircleCheck, CircleX } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +import { useResetPassword } from './hooks/use-reset-password'; + +export const ResetPassword = () => { + const t = useTranslations('core.sign_in.forgot_password'); + const tSignUp = useTranslations('core.sign_up'); + const { formSchema } = useResetPassword(); + + return ( + { + return ( +
+ + + + + + + + + + + + + + + +
+ ); + }, + }, + { + id: 'password', + label: tSignUp('password.label'), + component: props => { + const value: string = props.field.value ?? ''; + const regexArray = [ + { + regex: /^.{8,}$/.test(value), + id: 'min_length' as const, + }, + { + regex: /[A-Z]/.test(value), + id: 'uppercase' as const, + }, + { + regex: /\d/.test(value), + id: 'number' as const, + }, + { + regex: /\W|_/.test(value), + id: 'special_char' as const, + }, + ]; + + return ( + <> + +
+ + {tSignUp('password.desc')} + +
    + {regexArray.map(({ regex, id }, index) => ( +
  • + {regex ? ( + + ) : ( + + )} + + {tSignUp(`password.${id}`)} +
  • + ))} +
+
+ + ); + }, + }, + ]} + formSchema={formSchema} + submitButton={props => ( + <> + + + )} + /> + ); +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx b/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx index 89d29e9df..5597addaa 100644 --- a/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx +++ b/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx @@ -21,6 +21,7 @@ export const SignInView = async () => { { auth_methods, authorization: { lock_register }, + is_email_enabled, }, ] = await Promise.all([getTranslations('core.sign_in'), getMiddlewareData()]); @@ -38,9 +39,15 @@ export const SignInView = async () => { )}
- {auth_methods.sso.length > 0 && } + {is_email_enabled && ( +
+ + {t('forgot_password.title')} + +
+ )}
); }; diff --git a/packages/frontend/src/views/theme/views/dynamic-view.tsx b/packages/frontend/src/views/theme/views/dynamic-view.tsx index 8580a75c9..4c3e63e4b 100644 --- a/packages/frontend/src/views/theme/views/dynamic-view.tsx +++ b/packages/frontend/src/views/theme/views/dynamic-view.tsx @@ -3,6 +3,7 @@ import { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; import { notFound } from 'next/navigation'; +import { ForgotPasswordView } from './auth/sign/forgot_password/forgot_passowrd-view'; import { generateMetadataSignIn, SignInView, @@ -38,7 +39,17 @@ export const generateMetadataDynamic = async (props: { const { slug } = await props.params; if (slug[0] === 'login' && !slug[2]) { - return generateMetadataSignIn(); + if (slug[1] === 'forgot-password') { + const t = await getTranslations('core.sign_in.forgot_password'); + + return { + title: t('title'), + }; + } + + if (!slug[1]) { + return generateMetadataSignIn(); + } } if (slug[0] === 'register') { @@ -108,7 +119,11 @@ export const DynamicView = async (props: { return ; } - if (slug[2]) notFound(); + if (slug[1] === 'forgot-password' && !slug[2]) { + return ; + } + + if (slug[1]) notFound(); return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73fe299f7..8d589ec05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -648,6 +648,9 @@ importers: html-react-parser: specifier: ^5.1.18 version: 5.1.18(@types/react@18.3.12)(react@19.0.0-rc.1) + input-otp: + specifier: ^1.4.1 + version: 1.4.1(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1) lodash: specifier: ^4.17.21 version: 4.17.21 @@ -4829,6 +4832,12 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + input-otp@1.4.1: + resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + inquirer@8.2.6: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} @@ -11056,6 +11065,11 @@ snapshots: inline-style-parser@0.2.4: {} + input-otp@1.4.1(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1): + dependencies: + react: 19.0.0-rc.1 + react-dom: 19.0.0-rc.1(react@19.0.0-rc.1) + inquirer@8.2.6: dependencies: ansi-escapes: 4.3.2