mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge pull request #385 from documenso/feat/reset-password
feat: reset password
This commit is contained in:
20
apps/web/src/app/(unauthenticated)/check-email/page.tsx
Normal file
20
apps/web/src/app/(unauthenticated)/check-email/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
A password reset email has been sent, if you have an account you should see it in your inbox
|
||||||
|
shortly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/signin">Return to sign in</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
Normal file
25
apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
||||||
|
password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ForgotPasswordForm className="mt-4" />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Remembered your password?{' '}
|
||||||
|
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/web/src/app/(unauthenticated)/layout.tsx
Normal file
27
apps/web/src/app/(unauthenticated)/layout.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
|
type UnauthenticatedLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
||||||
|
return (
|
||||||
|
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||||
|
<div className="relative flex w-full max-w-md items-center gap-x-24">
|
||||||
|
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||||
|
<Image
|
||||||
|
src={backgroundPattern}
|
||||||
|
alt="background pattern"
|
||||||
|
className="dark:brightness-95 dark:invert dark:sepia"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
|
||||||
|
|
||||||
|
import { ResetPasswordForm } from '~/components/forms/reset-password';
|
||||||
|
|
||||||
|
type ResetPasswordPageProps = {
|
||||||
|
params: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ResetPasswordPage({ params: { token } }: ResetPasswordPageProps) {
|
||||||
|
const isValid = await getResetTokenValidity({ token });
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
redirect('/reset-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="text-4xl font-semibold">Reset Password</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
|
||||||
|
|
||||||
|
<ResetPasswordForm token={token} className="mt-4" />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/app/(unauthenticated)/reset-password/page.tsx
Normal file
20
apps/web/src/app/(unauthenticated)/reset-password/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
The token you have used to reset your password is either expired or it never existed. If you
|
||||||
|
have still forgotten your password, please request a new reset link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/signin">Return to sign in</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,23 +1,10 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import connections from '~/assets/card-sharing-figure.png';
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
<div>
|
||||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
|
||||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="dark:brightness-95 dark:invert dark:sepia"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-md">
|
|
||||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
@ -32,12 +19,15 @@ export default function SignInPage() {
|
|||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden flex-1 lg:block">
|
<p className="mt-2.5 text-center">
|
||||||
<Image src={connections} alt="documenso connections" />
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
|
Forgotten your password?
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,15 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import connections from '~/assets/connections.png';
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
return (
|
return (
|
||||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
<div>
|
||||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
|
||||||
<Image
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="dark:brightness-95 dark:invert dark:sepia"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-md">
|
|
||||||
<h1 className="text-4xl font-semibold">Create a shiny, new Documenso Account ✨</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
Create your account and start using state-of-the-art document signing. Open and
|
Create your account and start using state-of-the-art document signing. Open and beautiful
|
||||||
beautiful signing is within your grasp.
|
signing is within your grasp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignUpForm className="mt-4" />
|
<SignUpForm className="mt-4" />
|
||||||
@ -34,11 +21,5 @@ export default function SignUpPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden flex-1 lg:block">
|
|
||||||
<Image src={connections} alt="documenso connections" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
80
apps/web/src/components/forms/forgot-password.tsx
Normal file
80
apps/web/src/components/forms/forgot-password.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export const ZForgotPasswordFormSchema = z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||||
|
|
||||||
|
export type ForgotPasswordFormProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TForgotPasswordFormSchema>({
|
||||||
|
values: {
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZForgotPasswordFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
|
||||||
|
await forgotPassword({ email }).catch(() => null);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Reset email sent',
|
||||||
|
description:
|
||||||
|
'A password reset email has been sent, if you have an account you should see it in your inbox shortly.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
|
router.push('/check-email');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email" className="text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="lg" loading={isSubmitting}>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -88,7 +88,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="password" className="text-slate-500">
|
<Label htmlFor="password" className="text-muted-foreground">
|
||||||
Password
|
Password
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="repeated-password" className="text-slate-500">
|
<Label htmlFor="repeated-password" className="text-muted-foreground">
|
||||||
Repeat Password
|
Repeat Password
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full-name" className="text-slate-500">
|
<Label htmlFor="full-name" className="text-muted-foreground">
|
||||||
Full Name
|
Full Name
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email" className="text-slate-500">
|
<Label htmlFor="email" className="text-muted-foreground">
|
||||||
Email
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="signature" className="text-slate-500">
|
<Label htmlFor="signature" className="text-muted-foreground">
|
||||||
Signature
|
Signature
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
|
|||||||
135
apps/web/src/components/forms/reset-password.tsx
Normal file
135
apps/web/src/components/forms/reset-password.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export const ZResetPasswordFormSchema = z
|
||||||
|
.object({
|
||||||
|
password: z.string().min(6).max(72),
|
||||||
|
repeatedPassword: z.string().min(6).max(72),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.repeatedPassword, {
|
||||||
|
path: ['repeatedPassword'],
|
||||||
|
message: "Passwords don't match",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||||
|
|
||||||
|
export type ResetPasswordFormProps = {
|
||||||
|
className?: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TResetPasswordFormSchema>({
|
||||||
|
values: {
|
||||||
|
password: '',
|
||||||
|
repeatedPassword: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZResetPasswordFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
|
||||||
|
try {
|
||||||
|
await resetPassword({
|
||||||
|
password,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Password updated',
|
||||||
|
description: 'Your password has been updated successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/signin');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to reset your password. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password" className="text-muted-foreground">
|
||||||
|
<span>Password</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
|
||||||
|
<span>Repeat Password</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="repeatedPassword"
|
||||||
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('repeatedPassword')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="lg" loading={isSubmitting}>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@ import { z } from 'zod';
|
|||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@ -113,18 +114,18 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email" className="text-slate-500">
|
<Label htmlFor="email" className="text-muted-forground">
|
||||||
Email
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||||
|
|
||||||
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
|
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="password" className="text-slate-500">
|
<Label htmlFor="password" className="text-muted-forground">
|
||||||
Password
|
<span>Password</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@ -137,9 +138,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.password && (
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
<span className="mt-1 text-xs text-red-500">{errors.password.message}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||||
|
|||||||
1354
package-lock.json
generated
1354
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,11 +17,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.7",
|
||||||
"nodemailer": "^6.9.3"
|
"nodemailer": "^6.9.3",
|
||||||
|
"react-email": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tsconfig": "*",
|
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@documenso/tsconfig": "*",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.8",
|
||||||
"tsup": "^7.1.0"
|
"tsup": "^7.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import { Link, Section, Text } from '@react-email/components';
|
import { Link, Section, Text } from '@react-email/components';
|
||||||
|
|
||||||
export const TemplateFooter = () => {
|
export type TemplateFooterProps = {
|
||||||
|
isDocument?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
|
{isDocument && (
|
||||||
<Text className="my-4 text-base text-slate-400">
|
<Text className="my-4 text-base text-slate-400">
|
||||||
This document was sent using{' '}
|
This document was sent using{' '}
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||||
Documenso.
|
Documenso.
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text className="my-8 text-sm text-slate-400">
|
<Text className="my-8 text-sm text-slate-400">
|
||||||
Documenso
|
Documenso
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
export type TemplateForgotPasswordProps = {
|
||||||
|
resetPasswordLink: string;
|
||||||
|
assetBaseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateForgotPassword = ({
|
||||||
|
resetPasswordLink,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateForgotPasswordProps) => {
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section className="mt-4 flex-row items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
|
Forgot your password?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
That's okay, it happens! Click the button below to reset your password.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-6 mt-8 text-center">
|
||||||
|
<Button
|
||||||
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={resetPasswordLink}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateForgotPassword;
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { Img, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
export interface TemplateResetPasswordProps {
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
assetBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Section className="mt-4 flex-row items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
|
Password updated!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
Your password has been updated.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateResetPassword;
|
||||||
74
packages/email/templates/forgot-password.tsx
Normal file
74
packages/email/templates/forgot-password.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import TemplateFooter from '../template-components/template-footer';
|
||||||
|
import {
|
||||||
|
TemplateForgotPassword,
|
||||||
|
TemplateForgotPasswordProps,
|
||||||
|
} from '../template-components/template-forgot-password';
|
||||||
|
|
||||||
|
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
|
||||||
|
|
||||||
|
export const ForgotPasswordTemplate = ({
|
||||||
|
resetPasswordLink = 'https://documenso.com',
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
}: ForgotPasswordTemplateProps) => {
|
||||||
|
const previewText = `Password Reset Requested`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateForgotPassword
|
||||||
|
resetPasswordLink={resetPasswordLink}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordTemplate;
|
||||||
102
packages/email/templates/reset-password.tsx
Normal file
102
packages/email/templates/reset-password.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import TemplateFooter from '../template-components/template-footer';
|
||||||
|
import {
|
||||||
|
TemplateResetPassword,
|
||||||
|
TemplateResetPasswordProps,
|
||||||
|
} from '../template-components/template-reset-password';
|
||||||
|
|
||||||
|
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
|
||||||
|
|
||||||
|
export const ResetPasswordTemplate = ({
|
||||||
|
userName = 'Lucas Smith',
|
||||||
|
userEmail = 'lucas@documenso.com',
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
}: ResetPasswordTemplateProps) => {
|
||||||
|
const previewText = `Password Reset Successful`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateResetPassword
|
||||||
|
userName={userName}
|
||||||
|
userEmail={userEmail}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container className="mx-auto mt-12 max-w-xl">
|
||||||
|
<Section>
|
||||||
|
<Text className="my-4 text-base font-semibold">
|
||||||
|
Hi, {userName}{' '}
|
||||||
|
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
|
||||||
|
({userEmail})
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-2 text-base text-slate-400">
|
||||||
|
We've changed your password as you asked. You can now sign in with your new
|
||||||
|
password.
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base text-slate-400">
|
||||||
|
Didn't request a password change? We are here to help you secure your account,
|
||||||
|
just{' '}
|
||||||
|
<Link className="text-documenso-700 font-normal" href="mailto:hi@documenso.com">
|
||||||
|
contact us.
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasswordTemplate;
|
||||||
53
packages/lib/server-only/auth/send-forgot-password.ts
Normal file
53
packages/lib/server-only/auth/send-forgot-password.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendForgotPasswordOptions {
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
PasswordResetToken: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = user.PasswordResetToken[0].token;
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
const resetPasswordLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/reset-password/${token}`;
|
||||||
|
|
||||||
|
const template = createElement(ForgotPasswordTemplate, {
|
||||||
|
assetBaseUrl,
|
||||||
|
resetPasswordLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Forgot Password?',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
42
packages/lib/server-only/auth/send-reset-password.ts
Normal file
42
packages/lib/server-only/auth/send-reset-password.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendResetPasswordOptions {
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
console.log({ assetBaseUrl });
|
||||||
|
|
||||||
|
const template = createElement(ResetPasswordTemplate, {
|
||||||
|
assetBaseUrl,
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.name || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Password Reset Success!',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
53
packages/lib/server-only/user/forgot-password.ts
Normal file
53
packages/lib/server-only/user/forgot-password.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
|
||||||
|
|
||||||
|
import { ONE_DAY, ONE_HOUR } from '../../constants/time';
|
||||||
|
import { sendForgotPassword } from '../auth/send-forgot-password';
|
||||||
|
|
||||||
|
export const forgotPassword = async ({ email }: TForgotPasswordFormSchema) => {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: email,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a token that was created in the last hour and hasn't expired
|
||||||
|
const existingToken = await prisma.passwordResetToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
expiry: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
gt: new Date(Date.now() - ONE_HOUR),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(18).toString('hex');
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
expiry: new Date(Date.now() + ONE_DAY),
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendForgotPassword({
|
||||||
|
userId: user.id,
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
|
};
|
||||||
19
packages/lib/server-only/user/get-reset-token-validity.ts
Normal file
19
packages/lib/server-only/user/get-reset-token-validity.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
type GetResetTokenValidityOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getResetTokenValidity = async ({ token }: GetResetTokenValidityOptions) => {
|
||||||
|
const found = await prisma.passwordResetToken.findFirst({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
expiry: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!found && found.expiry > new Date();
|
||||||
|
};
|
||||||
62
packages/lib/server-only/user/reset-password.ts
Normal file
62
packages/lib/server-only/user/reset-password.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { compare, hash } from 'bcrypt';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
import { sendResetPassword } from '../auth/send-reset-password';
|
||||||
|
|
||||||
|
export type ResetPasswordOptions = {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Invalid token provided. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundToken = await prisma.passwordResetToken.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
User: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundToken) {
|
||||||
|
throw new Error('Invalid token provided. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (now > foundToken.expiry) {
|
||||||
|
throw new Error('Token has expired. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSamePassword = await compare(password, foundToken.User.password || '');
|
||||||
|
|
||||||
|
if (isSamePassword) {
|
||||||
|
throw new Error('Your new password cannot be the same as your old password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: foundToken.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.passwordResetToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: foundToken.userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sendResetPassword({ userId: foundToken.userId });
|
||||||
|
};
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiry" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -32,6 +32,16 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription[]
|
Subscription Subscription[]
|
||||||
|
PasswordResetToken PasswordResetToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
token String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiry DateTime
|
||||||
|
userId Int
|
||||||
|
User User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||||
|
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import { ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema } from './schema';
|
import {
|
||||||
|
ZForgotPasswordFormSchema,
|
||||||
|
ZResetPasswordFormSchema,
|
||||||
|
ZUpdatePasswordMutationSchema,
|
||||||
|
ZUpdateProfileMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
export const profileRouter = router({
|
export const profileRouter = router({
|
||||||
updateProfile: authenticatedProcedure
|
updateProfile: authenticatedProcedure
|
||||||
@ -47,6 +54,40 @@ export const profileRouter = router({
|
|||||||
message = err.message;
|
message = err.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
forgotPassword: procedure.input(ZForgotPasswordFormSchema).mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { email } = input;
|
||||||
|
|
||||||
|
return await forgotPassword({
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { password, token } = input;
|
||||||
|
|
||||||
|
return await resetPassword({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
let message = 'We were unable to reset your password. Please try again.';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
message = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message,
|
message,
|
||||||
|
|||||||
@ -5,10 +5,20 @@ export const ZUpdateProfileMutationSchema = z.object({
|
|||||||
signature: z.string(),
|
signature: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
|
||||||
|
|
||||||
export const ZUpdatePasswordMutationSchema = z.object({
|
export const ZUpdatePasswordMutationSchema = z.object({
|
||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZForgotPasswordFormSchema = z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZResetPasswordFormSchema = z.object({
|
||||||
|
password: z.string().min(6),
|
||||||
|
token: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||||
|
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||||
|
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user