Merge pull request #307 from femyeda/feat/cal-69/password-reset
Feat/cal 69/password reset
This commit is contained in:
commit
8394b12a71
11 changed files with 709 additions and 45 deletions
19
lib/emails/buildMessageTemplate.ts
Normal file
19
lib/emails/buildMessageTemplate.ts
Normal 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
30
lib/emails/sendMail.ts
Normal 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;
|
20
lib/forgot-password/messaging/forgot-password.ts
Normal file
20
lib/forgot-password/messaging/forgot-password.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import buildMessageTemplate from "../../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,
|
||||||
|
});
|
||||||
|
};
|
|
@ -21,7 +21,9 @@
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dayjs": "^1.10.4",
|
"dayjs": "^1.10.4",
|
||||||
"googleapis": "^67.1.1",
|
"googleapis": "^67.1.1",
|
||||||
|
"handlebars": "^4.7.7",
|
||||||
"ics": "^2.27.0",
|
"ics": "^2.27.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
"next": "^10.2.0",
|
"next": "^10.2.0",
|
||||||
"next-auth": "^3.13.2",
|
"next-auth": "^3.13.2",
|
||||||
|
@ -37,6 +39,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^14.14.33",
|
"@types/node": "^14.14.33",
|
||||||
|
"@types/nodemailer": "^6.4.2",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react": "^17.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||||
"@typescript-eslint/parser": "^4.27.0",
|
"@typescript-eslint/parser": "^4.27.0",
|
||||||
|
|
77
pages/api/auth/forgot-password.ts
Normal file
77
pages/api/auth/forgot-password.ts
Normal 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 "../../../lib/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(405).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().add(6, "hours").toDate();
|
||||||
|
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
|
||||||
|
data: {
|
||||||
|
email: rawEmail,
|
||||||
|
expires: expiry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
passwordRequest = createdResetPasswordRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-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" });
|
||||||
|
}
|
||||||
|
}
|
60
pages/api/auth/reset-password.ts
Normal file
60
pages/api/auth/reset-password.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
231
pages/auth/forgot-password/[id].tsx
Normal file
231
pages/auth/forgot-password/[id].tsx
Normal 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'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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
153
pages/auth/forgot-password/index.tsx
Normal file
153
pages/auth/forgot-password/index.tsx
Normal 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 }),
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,55 +1,79 @@
|
||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import { getCsrfToken } from 'next-auth/client';
|
import Link from "next/link";
|
||||||
|
import { getCsrfToken } from "next-auth/client";
|
||||||
|
|
||||||
export default function Login({ csrfToken }) {
|
export default function Login({ csrfToken }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<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">
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Sign in to your account</h2>
|
||||||
Sign in to your account
|
</div>
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<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">
|
<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">
|
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
|
||||||
<input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/>
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<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" />
|
<input
|
||||||
</div>
|
id="email"
|
||||||
</div>
|
name="email"
|
||||||
|
type="email"
|
||||||
<div>
|
autoComplete="email"
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
placeholder="john.doe@example.com"
|
||||||
Password
|
required
|
||||||
</label>
|
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 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>
|
|
||||||
|
|
||||||
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Login.getInitialProps = async ({ req, res }) => {
|
Login.getInitialProps = async ({ req }) => {
|
||||||
return {
|
return {
|
||||||
csrfToken: await getCsrfToken({ req })
|
csrfToken: await getCsrfToken({ req }),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
@ -142,3 +142,11 @@ model EventTypeCustomInput {
|
||||||
required Boolean
|
required Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ResetPasswordRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
email String
|
||||||
|
expires DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
|
41
yarn.lock
41
yarn.lock
|
@ -341,6 +341,13 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215"
|
||||||
integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA==
|
integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA==
|
||||||
|
|
||||||
|
"@types/nodemailer@^6.4.2":
|
||||||
|
version "6.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.2.tgz#d8ee254c969e6ad83fb9a0a0df3a817406a3fa3b"
|
||||||
|
integrity sha512-yhsqg5Xbr8aWdwjFS3QjkniW5/tLpWXtOYQcJdo9qE3DolBxsKzgRCQrteaMY0hos8MklJNSEsMqDpZynGzMNg==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/parse-json@^4.0.0":
|
"@types/parse-json@^4.0.0":
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||||
|
@ -1996,6 +2003,18 @@ gtoken@^5.0.4:
|
||||||
google-p12-pem "^3.0.3"
|
google-p12-pem "^3.0.3"
|
||||||
jws "^4.0.0"
|
jws "^4.0.0"
|
||||||
|
|
||||||
|
handlebars@^4.7.7:
|
||||||
|
version "4.7.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
|
||||||
|
integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==
|
||||||
|
dependencies:
|
||||||
|
minimist "^1.2.5"
|
||||||
|
neo-async "^2.6.0"
|
||||||
|
source-map "^0.6.1"
|
||||||
|
wordwrap "^1.0.0"
|
||||||
|
optionalDependencies:
|
||||||
|
uglify-js "^3.1.4"
|
||||||
|
|
||||||
has-ansi@^2.0.0:
|
has-ansi@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
|
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
|
||||||
|
@ -2618,6 +2637,11 @@ lodash.clonedeep@^4.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||||
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
|
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:
|
lodash.includes@^4.3.0:
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||||
|
@ -2789,7 +2813,7 @@ minimatch@^3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^1.1.7"
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
minimist@^1.1.1, minimist@^1.2.0:
|
minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
|
||||||
version "1.2.5"
|
version "1.2.5"
|
||||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
|
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
|
||||||
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
|
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
|
||||||
|
@ -2850,6 +2874,11 @@ natural-compare@^1.4.0:
|
||||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||||
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
|
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
|
||||||
|
|
||||||
|
neo-async@^2.6.0:
|
||||||
|
version "2.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||||
|
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||||
|
|
||||||
next-auth@^3.13.2:
|
next-auth@^3.13.2:
|
||||||
version "3.19.8"
|
version "3.19.8"
|
||||||
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7"
|
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7"
|
||||||
|
@ -4342,6 +4371,11 @@ typescript@^4.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
|
||||||
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
|
||||||
|
|
||||||
|
uglify-js@^3.1.4:
|
||||||
|
version "3.13.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.9.tgz#4d8d21dcd497f29cfd8e9378b9df123ad025999b"
|
||||||
|
integrity sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g==
|
||||||
|
|
||||||
unbox-primitive@^1.0.0, unbox-primitive@^1.0.1:
|
unbox-primitive@^1.0.0, unbox-primitive@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
||||||
|
@ -4498,6 +4532,11 @@ word-wrap@^1.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
||||||
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
|
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
|
||||||
|
|
||||||
|
wordwrap@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||||
|
integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
|
||||||
|
|
||||||
wrap-ansi@^6.2.0:
|
wrap-ansi@^6.2.0:
|
||||||
version "6.2.0"
|
version "6.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||||
|
|
Loading…
Reference in a new issue