Merge pull request #307 from femyeda/feat/cal-69/password-reset

Feat/cal 69/password reset
This commit is contained in:
Bailey Pumfleet 2021-06-25 16:52:36 +01:00 committed by GitHub
commit 8394b12a71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 709 additions and 45 deletions

View File

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

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

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

View File

@ -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,
});
};

View File

@ -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",

View File

@ -0,0 +1,77 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma";
import dayjs from "dayjs";
import { User, ResetPasswordRequest } from "@prisma/client";
import sendEmail from "../../../lib/emails/sendMail";
import { buildForgotPasswordMessage } from "../../../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" });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"