refactored email verification

This commit is contained in:
daniel31x13 2024-05-16 15:02:22 -04:00
parent db446d450f
commit f68ca100a1
16 changed files with 1285 additions and 135 deletions

View File

@ -36,6 +36,7 @@ SPACES_FORCE_PATH_STYLE=
NEXT_PUBLIC_EMAIL_PROVIDER= NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM= EMAIL_FROM=
EMAIL_SERVER= EMAIL_SERVER=
BASE_URL=
# Proxy settings # Proxy settings
PROXY= PROXY=

View File

@ -0,0 +1,67 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import Modal from "../Modal";
type Props = {
onClose: Function;
onSubmit: Function;
oldEmail: string;
newEmail: string;
};
export default function EmailChangeVerificationModal({
onClose,
onSubmit,
oldEmail,
newEmail,
}: Props) {
const [password, setPassword] = useState("");
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Confirm Password</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-5">
<p>
Please confirm your password before changing your email address.{" "}
{process.env.NEXT_PUBLIC_STRIPE === "true"
? "Updating this field will change your billing email on Stripe as well."
: undefined}
</p>
<div>
<p>Old Email</p>
<p className="text-neutral">{oldEmail}</p>
</div>
<div>
<p>New Email</p>
<p className="text-neutral">{newEmail}</p>
</div>
<div className="w-full">
<p className="mb-2">Password</p>
<TextInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••••"
className="bg-base-200"
type="password"
autoFocus
/>
</div>
<div className="flex justify-end items-center">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={() => onSubmit(password)}
>
Confirm
</button>
</div>
</div>
</Modal>
);
}

View File

@ -3,8 +3,8 @@ import { AccountSettings } from "@/types/global";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@ -13,17 +13,6 @@ export default async function updateUserById(
userId: number, userId: number,
data: AccountSettings data: AccountSettings
) { ) {
const ssoUser = await prisma.account.findFirst({
where: {
userId: userId,
},
});
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (emailEnabled && !data.email) if (emailEnabled && !data.email)
return { return {
response: "Email invalid.", response: "Email invalid.",
@ -39,6 +28,7 @@ export default async function updateUserById(
response: "Password must be at least 8 characters.", response: "Password must be at least 8 characters.",
status: 400, status: 400,
}; };
// Check email (if enabled) // Check email (if enabled)
const checkEmail = const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
@ -126,11 +116,42 @@ export default async function updateUserById(
removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
} }
const previousEmail = ( // Email Settings
await prisma.user.findUnique({ where: { id: userId } })
)?.email;
// Other settings const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, password: true },
});
if (user && user.email && data.email && data.email !== user.email) {
if (!data.password) {
return {
response: "Invalid password.",
status: 400,
};
}
// Verify password
if (!user.password) {
return {
response: "User has no password.",
status: 400,
};
}
const passwordMatch = bcrypt.compareSync(data.password, user.password);
if (!passwordMatch) {
return {
response: "Password is incorrect.",
status: 400,
};
}
sendChangeEmailVerificationRequest(user.email, data.email, data.name);
}
// Other settings / Apply changes
const saltRounds = 10; const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds); const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
@ -142,7 +163,6 @@ export default async function updateUserById(
data: { data: {
name: data.name, name: data.name,
username: data.username?.toLowerCase().trim(), username: data.username?.toLowerCase().trim(),
email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate, isPrivate: data.isPrivate,
image: image:
data.image && data.image.startsWith("http") data.image && data.image.startsWith("http")
@ -211,15 +231,6 @@ export default async function updateUserById(
}); });
} }
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email)
await updateCustomerEmail(
STRIPE_SECRET_KEY,
previousEmail as string,
data.email as string
);
const response: Omit<AccountSettings, "password"> = { const response: Omit<AccountSettings, "password"> = {
...userInfo, ...userInfo,
whitelistedUsers: newWhitelistedUsernames, whitelistedUsers: newWhitelistedUsernames,

View File

@ -0,0 +1,54 @@
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 sendChangeEmailVerificationRequest(
oldEmail: string,
newEmail: string,
user: string
) {
const token = randomBytes(32).toString("hex");
await prisma.$transaction(async () => {
await prisma.verificationToken.create({
data: {
identifier: oldEmail?.toLowerCase(),
token,
expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day
},
});
await prisma.user.update({
where: {
email: oldEmail?.toLowerCase(),
},
data: {
unverifiedNewEmail: newEmail?.toLowerCase(),
},
});
});
const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "verifyEmailChange.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
transporter.sendMail({
from: process.env.EMAIL_FROM,
to: newEmail,
subject: "Verify your new Linkwarden email address",
html: emailTemplate({
user,
baseUrl: process.env.BASE_URL,
oldEmail,
newEmail,
verifyUrl: `${process.env.BASE_URL}/auth/verify-email?token=${token}`,
}),
});
}

View File

@ -1,19 +1,33 @@
import { Theme } from "next-auth"; import { readFileSync } from "fs";
import { SendVerificationRequestParams } from "next-auth/providers"; import { SendVerificationRequestParams } from "next-auth/providers";
import { createTransport } from "nodemailer"; import path from "path";
import Handlebars from "handlebars";
import transporter from "./transporter";
export default async function sendVerificationRequest( export default async function sendVerificationRequest(
params: SendVerificationRequestParams params: SendVerificationRequestParams
) { ) {
const { identifier, url, provider, theme } = params; const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "verifyEmail.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
const { identifier, url, provider, token } = params;
const { host } = new URL(url); const { host } = new URL(url);
const transport = createTransport(provider.server); const result = await transporter.sendMail({
const result = await transport.sendMail({
to: identifier, to: identifier,
from: provider.from, from: provider.from,
subject: `Sign in to ${host}`, subject: `Please verify your email address`,
text: text({ url, host }), text: text({ url, host }),
html: html({ url, host, theme }), html: emailTemplate({
url: `${
process.env.NEXTAUTH_URL
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
}),
}); });
const failed = result.rejected.concat(result.pending).filter(Boolean); const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) { if (failed.length) {
@ -21,55 +35,6 @@ export default async function sendVerificationRequest(
} }
} }
function html(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params;
const escapedHost = host.replace(/\./g, "&#8203;.");
const brandColor = theme.brandColor || "#0029cf";
const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText: theme.buttonText || "#fff",
};
return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}">
<a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">
Sign in
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`;
}
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */ /** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) { function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`; return `Sign in to ${host}\n${url}\n\n`;

8
lib/api/transporter.ts Normal file
View File

@ -0,0 +1,8 @@
import { createTransport } from "nodemailer";
export default createTransport({
url: process.env.EMAIL_SERVER,
auth: {
user: process.env.EMAIL_FROM,
},
});

View File

@ -46,6 +46,7 @@
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"formidable": "^3.5.1", "formidable": "^3.5.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"handlebars": "^4.7.8",
"himalaya": "^1.1.0", "himalaya": "^1.1.0",
"jimp": "^0.22.10", "jimp": "^0.22.10",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",

View File

@ -0,0 +1,116 @@
import { prisma } from "@/lib/api/db";
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function verifyEmail(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "POST") {
const token = req.query.token;
if (!token || typeof token !== "string") {
return res.status(400).json({
response: "Invalid token.",
});
}
// Check token in db
const verifyToken = await prisma.verificationToken.findFirst({
where: {
token,
expires: {
gte: new Date(),
},
},
});
const oldEmail = verifyToken?.identifier;
if (!oldEmail) {
return res.status(400).json({
response: "Invalid token.",
});
}
// Ensure email isn't in use
const findNewEmail = await prisma.user.findFirst({
where: {
email: oldEmail,
},
select: {
unverifiedNewEmail: true,
},
});
const newEmail = findNewEmail?.unverifiedNewEmail;
if (!newEmail) {
return res.status(400).json({
response: "No unverified emails found.",
});
}
const emailInUse = await prisma.user.findFirst({
where: {
email: newEmail,
},
select: {
email: true,
},
});
console.log(emailInUse);
if (emailInUse) {
return res.status(400).json({
response: "Email is already in use.",
});
}
// Remove SSO provider
await prisma.account.deleteMany({
where: {
user: {
email: oldEmail,
},
},
});
// Update email in db
await prisma.user.update({
where: {
email: oldEmail,
},
data: {
email: newEmail.toLowerCase().trim(),
unverifiedNewEmail: null,
},
});
// Apply to Stripe
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY)
await updateCustomerEmail(STRIPE_SECRET_KEY, oldEmail, newEmail);
// Clean up existing tokens
await prisma.verificationToken.delete({
where: {
token,
},
});
await prisma.verificationToken.deleteMany({
where: {
identifier: oldEmail,
},
});
return res.status(200).json({
response: token,
});
}
}
// http://localhost:3000/api/v1/auth/verify-email?token=67b3d33491be1ba7b9ab60a8cb8caec5f248b72c0e890aafec979c0d33899279

View File

@ -0,0 +1,33 @@
import { useRouter } from "next/router";
import { useEffect } from "react";
import toast from "react-hot-toast";
const VerifyEmail = () => {
const router = useRouter();
useEffect(() => {
const token = router.query.token;
if (!token || typeof token !== "string") {
router.push("/login");
}
// Verify token
fetch(`/api/v1/auth/verify-email?token=${token}`, {
method: "POST",
}).then((res) => {
if (res.ok) {
toast.success("Email verified. You can now login.");
} else {
toast.error("Invalid token.");
}
});
console.log(token);
}, []);
return <div>Verify email...</div>;
};
export default VerifyEmail;

View File

@ -12,9 +12,13 @@ import { MigrationFormat, MigrationRequest } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import Checkbox from "@/components/Checkbox"; import Checkbox from "@/components/Checkbox";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal";
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
export default function Account() { export default function Account() {
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const [emailChangeVerificationModal, setEmailChangeVerificationModal] =
useState(false);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@ -30,6 +34,7 @@ export default function Account() {
username: "", username: "",
email: "", email: "",
emailVerified: null, emailVerified: null,
password: undefined,
image: "", image: "",
isPrivate: true, isPrivate: true,
// @ts-ignore // @ts-ignore
@ -68,19 +73,29 @@ export default function Account() {
} }
}; };
const submit = async () => { const submit = async (password?: string) => {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading("Applying..."); const load = toast.loading("Applying...");
const response = await updateAccount({ const response = await updateAccount({
...user, ...user,
// @ts-ignore
password: password ? password : undefined,
}); });
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (response.ok) {
const emailChanged = account.email !== user.email;
if (emailChanged) {
toast.success("Settings Applied!"); toast.success("Settings Applied!");
toast.success(
"Email change request sent. Please verify the new email address."
);
setEmailChangeVerificationModal(false);
} else toast.success("Settings Applied!");
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
}; };
@ -177,12 +192,6 @@ export default function Account() {
{emailEnabled ? ( {emailEnabled ? (
<div> <div>
<p className="mb-2">Email</p> <p className="mb-2">Email</p>
{user.email !== account.email &&
process.env.NEXT_PUBLIC_STRIPE === "true" ? (
<p className="text-neutral mb-2 text-sm">
Updating this field will change your billing email as well
</p>
) : undefined}
<TextInput <TextInput
value={user.email || ""} value={user.email || ""}
className="bg-base-200" className="bg-base-200"
@ -230,6 +239,47 @@ export default function Account() {
</div> </div>
</div> </div>
<div className="sm:-mt-3">
<Checkbox
label="Make profile private"
state={user.isPrivate}
onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })}
/>
<p className="text-neutral text-sm">
This will limit who can find and add you to new Collections.
</p>
{user.isPrivate && (
<div className="pl-5">
<p className="mt-2">Whitelisted Users</p>
<p className="text-neutral text-sm mb-3">
Please provide the Username of the users you wish to grant
visibility to your profile. Separated by comma.
</p>
<textarea
className="w-full resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder="Your profile is hidden from everyone right now..."
value={whitelistedUsersTextbox}
onChange={(e) => setWhiteListedUsersTextbox(e.target.value)}
/>
</div>
)}
</div>
<SubmitButton
onClick={() => {
if (account.email !== user.email) {
setEmailChangeVerificationModal(true);
} else {
submit();
}
}}
loading={submitLoader}
label="Save Changes"
className="mt-2 w-full sm:w-fit"
/>
<div> <div>
<div className="flex items-center gap-2 w-full rounded-md h-8"> <div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="truncate w-full pr-7 text-3xl font-thin"> <p className="truncate w-full pr-7 text-3xl font-thin">
@ -310,49 +360,6 @@ export default function Account() {
</div> </div>
</div> </div>
<div>
<div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="truncate w-full pr-7 text-3xl font-thin">
Profile Visibility
</p>
</div>
<div className="divider my-3"></div>
<Checkbox
label="Make profile private"
state={user.isPrivate}
onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })}
/>
<p className="text-neutral text-sm">
This will limit who can find and add you to new Collections.
</p>
{user.isPrivate && (
<div className="pl-5">
<p className="mt-2">Whitelisted Users</p>
<p className="text-neutral text-sm mb-3">
Please provide the Username of the users you wish to grant
visibility to your profile. Separated by comma.
</p>
<textarea
className="w-full resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder="Your profile is hidden from everyone right now..."
value={whitelistedUsersTextbox}
onChange={(e) => setWhiteListedUsersTextbox(e.target.value)}
/>
</div>
)}
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Save Changes"
className="mt-2 w-full sm:w-fit"
/>
<div> <div>
<div className="flex items-center gap-2 w-full rounded-md h-8"> <div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-red-500 dark:text-red-500 truncate w-full pr-7 text-3xl font-thin"> <p className="text-red-500 dark:text-red-500 truncate w-full pr-7 text-3xl font-thin">
@ -380,6 +387,15 @@ export default function Account() {
<p className="text-center w-full">Delete Your Account</p> <p className="text-center w-full">Delete Your Account</p>
</Link> </Link>
</div> </div>
{emailChangeVerificationModal ? (
<EmailChangeVerificationModal
onClose={() => setEmailChangeVerificationModal(false)}
onSubmit={submit}
oldEmail={account.email || ""}
newEmail={user.email || ""}
/>
) : undefined}
</SettingsLayout> </SettingsLayout>
); );
} }

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "unverifiedNewEmail" TEXT;

View File

@ -31,6 +31,7 @@ model User {
username String? @unique username String? @unique
email String? @unique email String? @unique
emailVerified DateTime? emailVerified DateTime?
unverifiedNewEmail String?
image String? image String?
accounts Account[] accounts Account[]
password String? password String?

413
templates/verifyEmail.html Normal file
View File

@ -0,0 +1,413 @@
<!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"
>
&nbsp;
</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;
"
>Please verify your email address by clicking the button
below.</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;
"
>
Verify your email address
</h1>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
"
>
Thank you for signing up with Linkwarden! 🥳
</p>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
"
>
Just one small step left. Simply verify your email address,
and youre all set!
</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;
"
>Verify Email</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<hr
style="
border: none;
border-top: 1px solid #eaebed;
margin-bottom: 24px;
width: 100%;
"
/>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 12px;
font-weight: normal;
margin: 0;
margin-bottom: 5px;
color: #868686;
"
>
If youre having trouble clicking the button, click on the
following link:
</p>
<span
style="
font-family: Helvetica, sans-serif;
font-size: 10px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
word-break: break-all;
"
>
{{url}}
</span>
</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"
>
&nbsp;
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,424 @@
<!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"
>
&nbsp;
</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;
"
>You recently requested to change the email address associated
with your Linkwarden account. To verify your new email address,
please click the button below.</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;
"
valign="top"
>
<h1
style="
font-family: Helvetica, sans-serif;
font-size: 24px;
font-weight: bold;
margin: 0;
margin-bottom: 16px;
"
>
Verify your new Linkwarden email address
</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;
"
>
You recently requested to change the email address
associated with your
<a href="{{baseUrl}}">Linkwarden</a> account. To verify your
new email address, please click the button below.
</p>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 5px;
"
>
<b>Old Email:</b>
</p>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
"
>
{{oldEmail}}
</p>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 5px;
"
>
<b>New Email:</b>
</p>
<p
style="
font-family: Helvetica, sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
"
>
{{newEmail}}
</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="{{verifyUrl}}"
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;
"
>Verify Email</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</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"
>
&nbsp;
</td>
</tr>
</table>
</body>
</html>

View File

@ -30,13 +30,14 @@ declare global {
EMAIL_FROM?: string; EMAIL_FROM?: string;
EMAIL_SERVER?: string; EMAIL_SERVER?: string;
BASE_URL?: string; // Used for email and stripe
NEXT_PUBLIC_STRIPE?: string; NEXT_PUBLIC_STRIPE?: string;
STRIPE_SECRET_KEY?: string; STRIPE_SECRET_KEY?: string;
MONTHLY_PRICE_ID?: string; MONTHLY_PRICE_ID?: string;
YEARLY_PRICE_ID?: string; YEARLY_PRICE_ID?: string;
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string; NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string;
NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string; NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string;
BASE_URL?: string;
// Proxy settings // Proxy settings
PROXY?: string; PROXY?: string;

View File

@ -3709,6 +3709,18 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
handlebars@^4.7.8:
version "4.7.8"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
har-schema@^2.0.0: har-schema@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
@ -4438,6 +4450,11 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
minimist@^1.2.5:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
minipass@^3.0.0: minipass@^3.0.0:
version "3.3.6" version "3.3.6"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
@ -4508,6 +4525,11 @@ ndarray@^1.0.13:
iota-array "^1.0.0" iota-array "^1.0.0"
is-buffer "^1.0.2" is-buffer "^1.0.2"
neo-async@^2.6.2:
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@^4.22.1: next-auth@^4.22.1:
version "4.22.1" version "4.22.1"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.22.1.tgz#1ea5084e38867966dc6492a71c6729c8f5cfa96b" resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.22.1.tgz#1ea5084e38867966dc6492a71c6729c8f5cfa96b"
@ -5532,6 +5554,11 @@ source-map@^0.5.7:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==
source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
spawn-command@0.0.2: spawn-command@0.0.2:
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e" resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e"
@ -5982,6 +6009,11 @@ typescript@4.9.4:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
uglify-js@^3.1.4:
version "3.17.4"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"
integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==
unbox-primitive@^1.0.2: unbox-primitive@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
@ -6218,6 +6250,11 @@ wide-align@^1.1.2:
dependencies: dependencies:
string-width "^1.0.2 || 2 || 3 || 4" string-width "^1.0.2 || 2 || 3 || 4"
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
wrap-ansi@^7.0.0: wrap-ansi@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"