Allow user to reset password
This commit is contained in:
parent
ed92451126
commit
ab1298e2ca
11 changed files with 672 additions and 44 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;
|
|
@ -23,6 +23,7 @@
|
||||||
"googleapis": "^67.1.1",
|
"googleapis": "^67.1.1",
|
||||||
"handlebars": "^4.7.7",
|
"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",
|
||||||
|
|
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 "../../../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" });
|
||||||
|
}
|
||||||
|
}
|
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,5 +1,6 @@
|
||||||
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 (
|
||||||
|
@ -9,21 +10,27 @@ export default function Login({ csrfToken }) {
|
||||||
<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
|
|
||||||
</h2>
|
|
||||||
</div>
|
</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
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
|
@ -32,24 +39,41 @@ export default function Login({ csrfToken }) {
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<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">
|
<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
|
Sign in
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
20
src/forgot-password/messaging/forgot-password.ts
Normal file
20
src/forgot-password/messaging/forgot-password.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
|
@ -2637,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"
|
||||||
|
|
Loading…
Reference in a new issue