Allow user to reset password

This commit is contained in:
femyeda 2021-06-24 10:59:11 -05:00
parent ed92451126
commit ab1298e2ca
11 changed files with 672 additions and 44 deletions

View File

@ -0,0 +1,19 @@
import Handlebars from "handlebars";
export const buildMessageTemplate = ({
messageTemplate,
subjectTemplate,
vars,
}): { subject: string; message: string } => {
const buildMessage = Handlebars.compile(messageTemplate);
const message = buildMessage(vars);
const buildSubject = Handlebars.compile(subjectTemplate);
const subject = buildSubject(vars);
return {
subject,
message,
};
};
export default buildMessageTemplate;

30
lib/emails/sendMail.ts Normal file
View File

@ -0,0 +1,30 @@
import { serverConfig } from "../serverConfig";
import nodemailer, { SentMessageInfo } from "nodemailer";
const sendEmail = ({ to, subject, text, html = null }): Promise<string | SentMessageInfo> =>
new Promise((resolve, reject) => {
const { transport, from } = serverConfig;
if (!to || !subject || (!text && !html)) {
return reject("Missing required elements to send email.");
}
nodemailer.createTransport(transport).sendMail(
{
from: `Calendso ${from}`,
to,
subject,
text,
html,
},
(error, info) => {
if (error) {
console.error("SEND_INVITATION_NOTIFICATION_ERROR", to, error);
return reject(error.message);
}
return resolve(info);
}
);
});
export default sendEmail;

View File

@ -23,6 +23,7 @@
"googleapis": "^67.1.1",
"handlebars": "^4.7.7",
"ics": "^2.27.0",
"lodash.debounce": "^4.0.8",
"lodash.merge": "^4.6.2",
"next": "^10.2.0",
"next-auth": "^3.13.2",

View File

@ -0,0 +1,77 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma";
import dayjs from "dayjs";
import { User, ResetPasswordRequest } from "@prisma/client";
import sendEmail from "../../../lib/emails/sendMail";
import { buildForgotPasswordMessage } from "../../../src/forgot-password/messaging/forgot-password";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(400).json({ message: "" });
}
try {
const rawEmail = req.body?.email;
const maybeUser: User = await prisma.user.findUnique({
where: {
email: rawEmail,
},
select: {
name: true,
},
});
if (!maybeUser) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const now = dayjs().toDate();
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
where: {
email: rawEmail,
expires: {
gt: now,
},
},
});
let passwordRequest: ResetPasswordRequest;
if (maybePreviousRequest && maybePreviousRequest?.length >= 1) {
passwordRequest = maybePreviousRequest[0];
} else {
const expiry = dayjs().tz(maybeUser.timeZone).add(6, "hours").toDate();
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
data: {
email: rawEmail,
expires: expiry,
},
});
passwordRequest = createdResetPasswordRequest;
}
const passwordResetLink = `${process.env.BASE_URL}/auth/reset-password/${passwordRequest.id}`;
const { subject, message } = buildForgotPasswordMessage({
user: {
name: maybeUser.name,
},
link: passwordResetLink,
});
await sendEmail({
to: rawEmail,
subject: subject,
text: message,
});
return res.status(201).json({ message: "Reset Requested", data: passwordRequest });
} catch (reason) {
console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
}
}

View File

@ -0,0 +1,60 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma";
import dayjs from "dayjs";
import { User, ResetPasswordRequest } from "@prisma/client";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
import { hashPassword } from "../../../lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(400).json({ message: "" });
}
try {
const rawPassword = req.body?.password;
const rawRequestId = req.body?.requestId;
if (!rawPassword || !rawRequestId) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const maybeRequest: ResetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
where: {
id: rawRequestId,
},
});
if (!maybeRequest) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const maybeUser: User = await prisma.user.findUnique({
where: {
email: maybeRequest.email,
},
});
if (!maybeUser) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const hashedPassword = await hashPassword(rawPassword);
await prisma.user.update({
where: {
id: maybeUser.id,
},
data: {
password: hashedPassword,
},
});
return res.status(201).json({ message: "Password reset." });
} catch (reason) {
console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
}
}

View File

@ -0,0 +1,231 @@
import { getCsrfToken } from "next-auth/client";
import prisma from "../../../lib/prisma";
import Head from "next/head";
import React from "react";
import debounce from "lodash.debounce";
import dayjs from "dayjs";
import { ResetPasswordRequest } from "@prisma/client";
import { useMemo } from "react";
import Link from "next/link";
import { GetServerSidePropsContext } from "next";
type Props = {
id: string;
resetPasswordRequest: ResetPasswordRequest;
csrfToken: string;
};
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const [success, setSuccess] = React.useState(false);
const [password, setPassword] = React.useState("");
const handleChange = (e) => {
setPassword(e.target.value);
};
const submitChangePassword = async ({ password, requestId }) => {
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
body: JSON.stringify({ requestId: requestId, password: password }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setError(json);
} else {
setSuccess(true);
}
return json;
} catch (reason) {
setError({ message: "An unexpected error occurred. Try again." });
} finally {
setLoading(false);
}
};
const debouncedChangePassword = debounce(submitChangePassword, 250);
const handleSubmit = async (e) => {
e.preventDefault();
if (!password) {
return;
}
if (loading) {
return;
}
setLoading(true);
setError(null);
setSuccess(false);
await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
};
const Success = () => {
return (
<>
<div className="space-y-6">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Success</h2>
</div>
<p>Your password has been reset. You can now login with your newly created password.</p>
<Link href="/auth/login">
<button
type="button"
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Login
</button>
</Link>
</div>
</>
);
};
const Expired = () => {
return (
<>
<div className="space-y-6">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Whoops</h2>
<h2 className="text-center text-3xl font-extrabold text-gray-900">That Request is Expired.</h2>
</div>
<p>
That request is expired. You can back and enter the email associated with your account and we will
you another link to reset your password.
</p>
<Link href="/auth/forgot-password">
<button
type="button"
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Try Again
</button>
</Link>
</div>
</>
);
};
const isRequestExpired = useMemo(() => {
const now = dayjs();
return dayjs(resetPasswordRequest.expires).isBefore(now);
}, [resetPasswordRequest]);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<Head>
<title>Reset Password</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10 space-y-6">
{isRequestExpired && <Expired />}
{!isRequestExpired && !success && (
<>
<div className="space-y-6">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Reset Password</h2>
<p>Enter the new password you&apos;d like for your account.</p>
{error && <p className="text-red-600">{error.message}</p>}
</div>
<form className="space-y-6" onSubmit={handleSubmit} action="#">
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
New Password
</label>
<div className="mt-1">
<input
onChange={handleChange}
id="password"
name="password"
type="password"
autoComplete="password"
required
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
loading ? "cursor-not-allowed" : ""
}`}>
{loading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
Submit
</button>
</div>
</form>
</>
)}
{!isRequestExpired && success && (
<>
<Success />
</>
)}
</div>
</div>
</div>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const id = context.params.id;
try {
const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
where: {
id: id,
},
select: {
id: true,
expires: true,
},
});
return {
props: {
resetPasswordRequest: {
...resetPasswordRequest,
expires: resetPasswordRequest.expires.toString(),
},
id,
csrfToken: await getCsrfToken({ req: context.req }),
},
};
} catch (reason) {
return {
notFound: true,
};
}
}

View File

@ -0,0 +1,153 @@
import Head from "next/head";
import React from "react";
import { getCsrfToken } from "next-auth/client";
import debounce from "lodash.debounce";
export default function Page({ csrfToken }) {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const [success, setSuccess] = React.useState(false);
const [email, setEmail] = React.useState("");
const handleChange = (e) => {
setEmail(e.target.value);
};
const submitForgotPasswordRequest = async ({ email }) => {
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email: email }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setError(json);
} else {
setSuccess(true);
}
return json;
} catch (reason) {
setError({ message: "An unexpected error occurred. Try again." });
} finally {
setLoading(false);
}
};
const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);
const handleSubmit = async (e) => {
e.preventDefault();
if (!email) {
return;
}
if (loading) {
return;
}
setLoading(true);
setError(null);
setSuccess(false);
await debouncedHandleSubmitPasswordRequest({ email });
};
const Success = () => {
return (
<div className="space-y-6">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Done</h2>
<p>Check your email. We sent you a link to reset your password.</p>
{error && <p className="text-red-600">{error.message}</p>}
</div>
);
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<Head>
<title>Forgot Password</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10 space-y-6">
{success && <Success />}
{!success && (
<>
<div className="space-y-6">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Forgot Password</h2>
<p>
Enter the email address associated with your account and we will send you a link to reset
your password.
</p>
{error && <p className="text-red-600">{error.message}</p>}
</div>
<form className="space-y-6" onSubmit={handleSubmit} action="#">
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
onChange={handleChange}
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="john.doe@example.com"
required
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
loading ? "cursor-not-allowed" : ""
}`}>
{loading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
Request Password Reset
</button>
</div>
</form>
</>
)}
</div>
</div>
</div>
);
}
Page.getInitialProps = async ({ req }) => {
return {
csrfToken: await getCsrfToken({ req }),
};
};

View File

@ -1,55 +1,79 @@
import Head from 'next/head';
import { getCsrfToken } from 'next-auth/client';
import Head from "next/head";
import Link from "next/link";
import { getCsrfToken } from "next-auth/client";
export default function Login({ csrfToken }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<Head>
<title>Login</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<Head>
<title>Login</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Sign in to your account</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10">
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
<input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input id="email" name="email" type="email" autoComplete="email" placeholder="john.doe@example.com" required className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input id="password" name="password" type="password" autoComplete="current-password" placeholder="•••••••••••••" required className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
</div>
</div>
<div>
<button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign in
</button>
</div>
</form>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10">
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="john.doe@example.com"
required
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="•••••••••••••"
required
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="space-y-2">
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign in
</button>
<Link href="/auth/forgot-password">
<button
type="button"
className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Forgot Password?
</button>
</Link>
</div>
</form>
</div>
</div>
</div>
)
);
}
Login.getInitialProps = async ({ req, res }) => {
Login.getInitialProps = async ({ req }) => {
return {
csrfToken: await getCsrfToken({ req })
}
}
csrfToken: await getCsrfToken({ req }),
};
};

View File

@ -142,3 +142,11 @@ model EventTypeCustomInput {
required Boolean
}
model ResetPasswordRequest {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String
expires DateTime
}

View File

@ -0,0 +1,20 @@
import buildMessageTemplate from "../../../lib/emails/buildMessageTemplate";
export const forgotPasswordSubjectTemplate = "Forgot your password? - Calendso";
export const forgotPasswordMessageTemplate = `Hey there,
Use the link below to reset your password.
{{link}}
p.s. It expires in 6 hours.
- Calendso`;
export const buildForgotPasswordMessage = (vars) => {
return buildMessageTemplate({
subjectTemplate: forgotPasswordSubjectTemplate,
messageTemplate: forgotPasswordMessageTemplate,
vars,
});
};

View File

@ -2637,6 +2637,11 @@ lodash.clonedeep@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"