Skip to content

Commit

Permalink
feat(frontend): Add forgot password view
Browse files Browse the repository at this point in the history
  • Loading branch information
aXenDeveloper committed Dec 3, 2024
1 parent 9715275 commit 8860f2b
Show file tree
Hide file tree
Showing 14 changed files with 343 additions and 10 deletions.
10 changes: 5 additions & 5 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
@apply bg-background text-foreground;
}

a {
@apply text-primary;
}

:root {
--background: 220 40% 98%;
--foreground: 213deg 5% 8%;
Expand Down
12 changes: 12 additions & 0 deletions apps/frontend/src/plugins/core/langs/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions packages/frontend/src/components/ui/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof OTPInput>) => (
<OTPInput
className={cn('disabled:cursor-not-allowed', className)}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName,
)}
{...props}
/>
);

const InputOTPGroup = ({
className,
...props
}: React.ComponentProps<'div'>) => (
<div className={cn('flex items-center', className)} {...props} />
);

const InputOTPSlot = ({
index,
className,
...props
}: React.ComponentProps<'div'> & { index: number }) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];

return (
<div
className={cn(
'border-input relative flex h-10 w-10 items-center justify-center border-y border-r text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'ring-ring ring-offset-background z-10 ring-2',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
};

const InputOTPSeparator = ({ ...props }: React.ComponentProps<'div'>) => (
<div role="separator" {...props}>
<Dot />
</div>
);

export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
3 changes: 1 addition & 2 deletions packages/frontend/src/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
'use client';

import * as ProgressPrimitive from '@radix-ui/react-progress';
import { ComponentProps } from 'react';

import { cn } from '../../helpers/classnames';

const Progress = ({
className,
value,
...props
}: ComponentProps<typeof ProgressPrimitive.Root>) => (
}: React.ComponentProps<typeof ProgressPrimitive.Root>) => (
<ProgressPrimitive.Root
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TranslationsProvider } from '@/components/translations-provider';
import { CardDescription } from '@/components/ui/card';
import { getTranslations } from 'next-intl/server';

import { FormForgotPassword } from './form';

export const ForgotPasswordView = async () => {
const t = await getTranslations('core.sign_in.forgot_password');

return (
<TranslationsProvider
namespaces={['core.sign_in.forgot_password', 'core.sign_up']}
>
<div className="container my-6 max-w-md py-10">
<div className="mb-10 space-y-2 text-center">
<h1 className="text-3xl font-semibold leading-none tracking-tight">
{t('title')}
</h1>
<CardDescription>{t('desc')}</CardDescription>
</div>

<FormForgotPassword />
</div>
</TranslationsProvider>
);
};
Original file line number Diff line number Diff line change
@@ -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 <ResetPassword />;
}

return (
<AutoForm
fields={[
{
id: 'email',
label: t('email'),
component: props => (
<AutoFormInput className="bg-card" {...props} type="email" />
),
},
]}
formSchema={formSchema}
onSubmit={onSubmit}
submitButton={props => (
<>
<Button asChild className="flex-1" variant="ghost">
<Link href="/login">
<ChevronLeftIcon /> {t('go_back_login')}
</Link>
</Button>
<Button className="flex-1" {...props}>
{t('send_code')}
</Button>
</>
)}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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<typeof formSchema>) => {
setEmail(values.email);
};

return { formSchema, email, onSubmit };
};
Original file line number Diff line number Diff line change
@@ -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 };
};
Original file line number Diff line number Diff line change
@@ -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 (
<AutoForm
fields={[
{
id: 'pin',
label: t('pin.label'),
description: t('pin.desc'),
component: ({ field, ...rest }) => {
return (
<div className="flex flex-col items-center justify-center gap-2">
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot className="bg-card" index={0} />
<InputOTPSlot className="bg-card" index={1} />
<InputOTPSlot className="bg-card" index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot className="bg-card" index={3} />
<InputOTPSlot className="bg-card" index={4} />
<InputOTPSlot className="bg-card" index={5} />
</InputOTPGroup>
</InputOTP>

<AutoFormTooltip {...rest} />
</div>
);
},
},
{
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 (
<>
<AutoFormInput
{...props}
className="bg-card shadow-sm"
type="password"
/>
<div className="text-sm">
<span className="text-muted-foreground">
{tSignUp('password.desc')}
</span>
<ul className="mt-1 space-y-1">
{regexArray.map(({ regex, id }, index) => (
<li
className="text-muted-foreground flex flex-wrap gap-2"
key={index}
>
{regex ? (
<CircleCheck className="text-primary size-5" />
) : (
<CircleX className="text-destructive size-5" />
)}

<span>{tSignUp(`password.${id}`)}</span>
</li>
))}
</ul>
</div>
</>
);
},
},
]}
formSchema={formSchema}
submitButton={props => (
<>
<Button {...props}>{t('change_password')}</Button>
</>
)}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const SignInView = async () => {
{
auth_methods,
authorization: { lock_register },
is_email_enabled,
},
] = await Promise.all([getTranslations('core.sign_in'), getMiddlewareData()]);

Expand All @@ -38,9 +39,15 @@ export const SignInView = async () => {
</CardDescription>
)}
</div>

{auth_methods.sso.length > 0 && <SSOSign />}
<FormSignIn />
{is_email_enabled && (
<div className="mt-4 flex items-center justify-end text-sm">
<Link className="text-right" href="/login/forgot-password">
{t('forgot_password.title')}
</Link>
</div>
)}
</div>
);
};
Loading

0 comments on commit 8860f2b

Please sign in to comment.