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",
|
||||
"dayjs": "^1.10.4",
|
||||
"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",
|
||||
|
@ -37,6 +39,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.14.33",
|
||||
"@types/nodemailer": "^6.4.2",
|
||||
"@types/react": "^17.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^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 { 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 }),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
41
yarn.lock
41
yarn.lock
|
@ -341,6 +341,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215"
|
||||
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":
|
||||
version "4.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.0.0"
|
||||
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"
|
||||
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"
|
||||
|
@ -2789,7 +2813,7 @@ minimatch@^3.0.4:
|
|||
dependencies:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
|
||||
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"
|
||||
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:
|
||||
version "3.19.8"
|
||||
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"
|
||||
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:
|
||||
version "1.0.1"
|
||||
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"
|
||||
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:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||
|
|
Loading…
Reference in a new issue