mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 02:27:00 +00:00
password reset functionality [WIP]
This commit is contained in:
44
lib/api/sendPasswordResetRequest.ts
Normal file
44
lib/api/sendPasswordResetRequest.ts
Normal file
@@ -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}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
52
pages/api/v1/auth/forgot-password.ts
Normal file
52
pages/api/v1/auth/forgot-password.ts
Normal file
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
80
pages/api/v1/auth/reset-password.ts
Normal file
80
pages/api/v1/auth/reset-password.ts
Normal file
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
116
pages/auth/reset-password.tsx
Normal file
116
pages/auth/reset-password.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -17,24 +16,40 @@ export default function Forgot() {
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (form.email !== "") {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading("Sending login link...");
|
||||
const load = toast.loading("Sending password recovery link...");
|
||||
|
||||
await signIn("email", {
|
||||
email: form.email,
|
||||
callbackUrl: "/",
|
||||
});
|
||||
await submitRequest();
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
setSubmitLoader(false);
|
||||
|
||||
toast.success("Login link sent.");
|
||||
} else {
|
||||
toast.error("Please fill out all the fields.");
|
||||
}
|
||||
@@ -45,40 +60,46 @@ export default function Forgot() {
|
||||
<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">
|
||||
Password Recovery
|
||||
{isEmailSent ? "Email Sent!" : "Forgot Password?"}
|
||||
</p>
|
||||
|
||||
<div className="divider my-0"></div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
Enter your email so we can send you a link to recover your
|
||||
account. Make sure to change your password in the profile settings
|
||||
afterwards.
|
||||
</p>
|
||||
<p className="text-sm text-neutral">
|
||||
You wont get logged in if you haven't created an account yet.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm w-fit font-semibold mb-1">Email</p>
|
||||
{!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="email"
|
||||
placeholder="johnny@example.com"
|
||||
value={form.email}
|
||||
className="bg-base-100"
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
autoFocus
|
||||
type="email"
|
||||
placeholder="johnny@example.com"
|
||||
value={form.email}
|
||||
className="bg-base-100"
|
||||
onChange={(e) => setForm({ ...form, email: 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>
|
||||
)}
|
||||
|
||||
<AccentSubmitButton
|
||||
type="submit"
|
||||
label="Send Login Link"
|
||||
className="mt-2 w-full"
|
||||
loading={submitLoader}
|
||||
/>
|
||||
<div className="flex items-baseline gap-1 justify-center">
|
||||
<Link href={"/login"} className="block font-bold">
|
||||
Go back
|
||||
|
||||
388
templates/passwordReset.html
Normal file
388
templates/passwordReset.html
Normal file
@@ -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