password reset functionality [WIP]
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import transporter from "./transporter";
|
||||||
|
import Handlebars from "handlebars";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default async function sendPasswordResetRequest(
|
||||||
|
email: string,
|
||||||
|
user: string
|
||||||
|
) {
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: email?.toLowerCase(),
|
||||||
|
token,
|
||||||
|
expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailsDir = path.resolve(process.cwd(), "templates");
|
||||||
|
|
||||||
|
const templateFile = readFileSync(
|
||||||
|
path.join(emailsDir, "passwordReset.html"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailTemplate = Handlebars.compile(templateFile);
|
||||||
|
|
||||||
|
transporter.sendMail({
|
||||||
|
from: {
|
||||||
|
name: "Linkwarden",
|
||||||
|
address: process.env.EMAIL_FROM as string,
|
||||||
|
},
|
||||||
|
to: email,
|
||||||
|
subject: "Verify your new Linkwarden email address",
|
||||||
|
html: emailTemplate({
|
||||||
|
user,
|
||||||
|
baseUrl: process.env.BASE_URL,
|
||||||
|
url: `${process.env.BASE_URL}/auth/password-reset?token=${token}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
export default async function forgotPassword(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const email = req.body.email;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Invalid email.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentPasswordRequestsCount = await prisma.passwordResetToken.count({
|
||||||
|
where: {
|
||||||
|
identifier: email,
|
||||||
|
createdAt: {
|
||||||
|
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit password reset requests
|
||||||
|
if (recentPasswordRequestsCount >= 3) {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Too many requests. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Invalid email.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPasswordResetRequest(user.email, user.name);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
response: "Password reset email sent.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
|
export default async function resetPassword(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const token = req.body.token;
|
||||||
|
const password = req.body.password;
|
||||||
|
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Password must be at least 8 characters.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Invalid token.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashed password
|
||||||
|
const saltRounds = 10;
|
||||||
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
|
|
||||||
|
// Check token in db
|
||||||
|
const verifyToken = await prisma.passwordResetToken.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
expires: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verifyToken) {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Invalid token.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = verifyToken.identifier;
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.update({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expires: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete tokens older than 5 minutes
|
||||||
|
await prisma.passwordResetToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
identifier: email,
|
||||||
|
createdAt: {
|
||||||
|
lt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
response: "Password reset successfully.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import CenteredForm from "@/layouts/CenteredForm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
password: string;
|
||||||
|
passwordConfirmation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormData>({
|
||||||
|
password: "",
|
||||||
|
passwordConfirmation: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEmailSent, setIsEmailSent] = useState(false);
|
||||||
|
|
||||||
|
async function submitRequest() {
|
||||||
|
const response = await fetch("/api/v1/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(data.response);
|
||||||
|
setIsEmailSent(true);
|
||||||
|
} else {
|
||||||
|
toast.error(data.response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendConfirmation(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (form.password !== "") {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Sending password recovery link...");
|
||||||
|
|
||||||
|
await submitRequest();
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
} else {
|
||||||
|
toast.error("Please fill out all the fields.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredForm>
|
||||||
|
<form onSubmit={sendConfirmation}>
|
||||||
|
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
||||||
|
<p className="text-3xl text-center font-extralight">
|
||||||
|
{isEmailSent ? "Email Sent!" : "Forgot Password?"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
|
{!isEmailSent ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Enter your email so we can send you a link to create a new
|
||||||
|
password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm w-fit font-semibold mb-1">Email</p>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
autoFocus
|
||||||
|
type="password"
|
||||||
|
placeholder="johnny@example.com"
|
||||||
|
value={form.password}
|
||||||
|
className="bg-base-100"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, password: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AccentSubmitButton
|
||||||
|
type="submit"
|
||||||
|
label="Send Login Link"
|
||||||
|
className="mt-2 w-full"
|
||||||
|
loading={submitLoader}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center">
|
||||||
|
Check your email for a link to reset your password. If it doesn’t
|
||||||
|
appear within a few minutes, check your spam folder.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-1 justify-center">
|
||||||
|
<Link href={"/login"} className="block font-bold">
|
||||||
|
Go back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CenteredForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
+57
-36
@@ -1,7 +1,6 @@
|
|||||||
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
||||||
import TextInput from "@/components/TextInput";
|
import TextInput from "@/components/TextInput";
|
||||||
import CenteredForm from "@/layouts/CenteredForm";
|
import CenteredForm from "@/layouts/CenteredForm";
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
@@ -17,24 +16,40 @@ export default function Forgot() {
|
|||||||
email: "",
|
email: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isEmailSent, setIsEmailSent] = useState(false);
|
||||||
|
|
||||||
|
async function submitRequest() {
|
||||||
|
const response = await fetch("/api/v1/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(data.response);
|
||||||
|
setIsEmailSent(true);
|
||||||
|
} else {
|
||||||
|
toast.error(data.response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sendConfirmation(event: FormEvent<HTMLFormElement>) {
|
async function sendConfirmation(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (form.email !== "") {
|
if (form.email !== "") {
|
||||||
setSubmitLoader(true);
|
setSubmitLoader(true);
|
||||||
|
|
||||||
const load = toast.loading("Sending login link...");
|
const load = toast.loading("Sending password recovery link...");
|
||||||
|
|
||||||
await signIn("email", {
|
await submitRequest();
|
||||||
email: form.email,
|
|
||||||
callbackUrl: "/",
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
setSubmitLoader(false);
|
||||||
|
|
||||||
toast.success("Login link sent.");
|
|
||||||
} else {
|
} else {
|
||||||
toast.error("Please fill out all the fields.");
|
toast.error("Please fill out all the fields.");
|
||||||
}
|
}
|
||||||
@@ -45,40 +60,46 @@ export default function Forgot() {
|
|||||||
<form onSubmit={sendConfirmation}>
|
<form onSubmit={sendConfirmation}>
|
||||||
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
||||||
<p className="text-3xl text-center font-extralight">
|
<p className="text-3xl text-center font-extralight">
|
||||||
Password Recovery
|
{isEmailSent ? "Email Sent!" : "Forgot Password?"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="divider my-0"></div>
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
<div>
|
{!isEmailSent ? (
|
||||||
<p>
|
<>
|
||||||
Enter your email so we can send you a link to recover your
|
<div>
|
||||||
account. Make sure to change your password in the profile settings
|
<p>
|
||||||
afterwards.
|
Enter your email so we can send you a link to create a new
|
||||||
</p>
|
password.
|
||||||
<p className="text-sm text-neutral">
|
</p>
|
||||||
You wont get logged in if you haven't created an account yet.
|
</div>
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<p className="text-sm w-fit font-semibold mb-1">Email</p>
|
||||||
<div>
|
|
||||||
<p className="text-sm w-fit font-semibold mb-1">Email</p>
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
autoFocus
|
autoFocus
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="johnny@example.com"
|
placeholder="johnny@example.com"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
className="bg-base-100"
|
className="bg-base-100"
|
||||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccentSubmitButton
|
||||||
|
type="submit"
|
||||||
|
label="Send Login Link"
|
||||||
|
className="mt-2 w-full"
|
||||||
|
loading={submitLoader}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center">
|
||||||
|
Check your email for a link to reset your password. If it doesn’t
|
||||||
|
appear within a few minutes, check your spam folder.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<AccentSubmitButton
|
|
||||||
type="submit"
|
|
||||||
label="Send Login Link"
|
|
||||||
className="mt-2 w-full"
|
|
||||||
loading={submitLoader}
|
|
||||||
/>
|
|
||||||
<div className="flex items-baseline gap-1 justify-center">
|
<div className="flex items-baseline gap-1 justify-center">
|
||||||
<Link href={"/login"} className="block font-bold">
|
<Link href={"/login"} className="block font-bold">
|
||||||
Go back
|
Go back
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Email</title>
|
||||||
|
<style media="all" type="text/css">
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
.main p,
|
||||||
|
.main td,
|
||||||
|
.main span {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
border-left-width: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border-right-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn table {
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn a {
|
||||||
|
font-size: 16px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media all {
|
||||||
|
.ExternalClass {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExternalClass,
|
||||||
|
.ExternalClass p,
|
||||||
|
.ExternalClass span,
|
||||||
|
.ExternalClass font,
|
||||||
|
.ExternalClass td,
|
||||||
|
.ExternalClass div {
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-link a {
|
||||||
|
color: inherit !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
font-weight: inherit !important;
|
||||||
|
line-height: inherit !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#MessageViewBody a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="body"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
bgcolor="#f8f8f8"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="container"
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: 24px;
|
||||||
|
width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
"
|
||||||
|
width="600"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="content"
|
||||||
|
style="
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- START CENTERED WHITE CONTAINER -->
|
||||||
|
<span
|
||||||
|
class="preheader"
|
||||||
|
style="
|
||||||
|
color: transparent;
|
||||||
|
display: none;
|
||||||
|
height: 0;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
visibility: hidden;
|
||||||
|
width: 0;
|
||||||
|
"
|
||||||
|
>Reset your password?</span
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="main"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #eaebed;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<!-- START MAIN CONTENT AREA -->
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="wrapper"
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 24px;
|
||||||
|
width: fit-content;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Reset your password?
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Hi {{user}}!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Someone has requested a link to change your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="btn btn-primary"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left"
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
width: auto;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: top;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #00335a;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
align="center"
|
||||||
|
bgcolor="#0867ec"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{url}}"
|
||||||
|
target="_blank"
|
||||||
|
style="
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: capitalize;
|
||||||
|
background-color: #00335a;
|
||||||
|
color: #ffffff;
|
||||||
|
"
|
||||||
|
>Change Password</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
If you didn't request this, you can safely ignore this email
|
||||||
|
and your password will not be changed.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- END MAIN CONTENT AREA -->
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- START FOOTER -->
|
||||||
|
<div
|
||||||
|
class="footer"
|
||||||
|
style="
|
||||||
|
clear: both;
|
||||||
|
padding-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="content-block"
|
||||||
|
style="vertical-align: top; text-align: center"
|
||||||
|
valign="top"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://raw.githubusercontent.com/linkwarden/linkwarden/main/public/linkwarden_light.png"
|
||||||
|
alt="logo"
|
||||||
|
style="width: 180px; height: auto"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- END FOOTER -->
|
||||||
|
|
||||||
|
<!-- END CENTERED WHITE CONTAINER -->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user