refactored email verification
This commit is contained in:
parent
db446d450f
commit
f68ca100a1
|
@ -36,6 +36,7 @@ SPACES_FORCE_PATH_STYLE=
|
|||
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||
EMAIL_FROM=
|
||||
EMAIL_SERVER=
|
||||
BASE_URL=
|
||||
|
||||
# Proxy settings
|
||||
PROXY=
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -3,8 +3,8 @@ import { AccountSettings } from "@/types/global";
|
|||
import bcrypt from "bcrypt";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import createFile from "@/lib/api/storage/createFile";
|
||||
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
@ -13,17 +13,6 @@ export default async function updateUserById(
|
|||
userId: number,
|
||||
data: AccountSettings
|
||||
) {
|
||||
const ssoUser = await prisma.account.findFirst({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailEnabled && !data.email)
|
||||
return {
|
||||
response: "Email invalid.",
|
||||
|
@ -39,6 +28,7 @@ export default async function updateUserById(
|
|||
response: "Password must be at least 8 characters.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
// Check email (if enabled)
|
||||
const checkEmail =
|
||||
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
|
||||
|
@ -126,11 +116,42 @@ export default async function updateUserById(
|
|||
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
}
|
||||
|
||||
const previousEmail = (
|
||||
await prisma.user.findUnique({ where: { id: userId } })
|
||||
)?.email;
|
||||
// Email Settings
|
||||
|
||||
// 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 newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
|
||||
|
@ -142,7 +163,6 @@ export default async function updateUserById(
|
|||
data: {
|
||||
name: data.name,
|
||||
username: data.username?.toLowerCase().trim(),
|
||||
email: data.email?.toLowerCase().trim(),
|
||||
isPrivate: data.isPrivate,
|
||||
image:
|
||||
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"> = {
|
||||
...userInfo,
|
||||
whitelistedUsers: newWhitelistedUsernames,
|
||||
|
|
|
@ -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}`,
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -1,19 +1,33 @@
|
|||
import { Theme } from "next-auth";
|
||||
import { readFileSync } from "fs";
|
||||
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(
|
||||
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 transport = createTransport(provider.server);
|
||||
const result = await transport.sendMail({
|
||||
const result = await transporter.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
subject: `Please verify your email address`,
|
||||
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);
|
||||
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, "​.");
|
||||
|
||||
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) */
|
||||
function text({ url, host }: { url: string; host: string }) {
|
||||
return `Sign in to ${host}\n${url}\n\n`;
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { createTransport } from "nodemailer";
|
||||
|
||||
export default createTransport({
|
||||
url: process.env.EMAIL_SERVER,
|
||||
auth: {
|
||||
user: process.env.EMAIL_FROM,
|
||||
},
|
||||
});
|
|
@ -46,6 +46,7 @@
|
|||
"eslint-config-next": "13.4.9",
|
||||
"formidable": "^3.5.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"himalaya": "^1.1.0",
|
||||
"jimp": "^0.22.10",
|
||||
"jsdom": "^22.1.0",
|
||||
|
|
|
@ -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
|
|
@ -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;
|
|
@ -12,9 +12,13 @@ import { MigrationFormat, MigrationRequest } from "@/types/global";
|
|||
import Link from "next/link";
|
||||
import Checkbox from "@/components/Checkbox";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal";
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
||||
export default function Account() {
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
const [emailChangeVerificationModal, setEmailChangeVerificationModal] =
|
||||
useState(false);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
|
@ -30,6 +34,7 @@ export default function Account() {
|
|||
username: "",
|
||||
email: "",
|
||||
emailVerified: null,
|
||||
password: undefined,
|
||||
image: "",
|
||||
isPrivate: true,
|
||||
// @ts-ignore
|
||||
|
@ -68,19 +73,29 @@ export default function Account() {
|
|||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const submit = async (password?: string) => {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading("Applying...");
|
||||
|
||||
const response = await updateAccount({
|
||||
...user,
|
||||
// @ts-ignore
|
||||
password: password ? password : undefined,
|
||||
});
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Settings Applied!");
|
||||
const emailChanged = account.email !== user.email;
|
||||
|
||||
if (emailChanged) {
|
||||
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);
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
@ -177,12 +192,6 @@ export default function Account() {
|
|||
{emailEnabled ? (
|
||||
<div>
|
||||
<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
|
||||
value={user.email || ""}
|
||||
className="bg-base-200"
|
||||
|
@ -230,6 +239,47 @@ export default function Account() {
|
|||
</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 className="flex items-center gap-2 w-full rounded-md h-8">
|
||||
<p className="truncate w-full pr-7 text-3xl font-thin">
|
||||
|
@ -310,49 +360,6 @@ export default function Account() {
|
|||
</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 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">
|
||||
|
@ -380,6 +387,15 @@ export default function Account() {
|
|||
<p className="text-center w-full">Delete Your Account</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{emailChangeVerificationModal ? (
|
||||
<EmailChangeVerificationModal
|
||||
onClose={() => setEmailChangeVerificationModal(false)}
|
||||
onSubmit={submit}
|
||||
oldEmail={account.email || ""}
|
||||
newEmail={user.email || ""}
|
||||
/>
|
||||
) : undefined}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "unverifiedNewEmail" TEXT;
|
|
@ -31,6 +31,7 @@ model User {
|
|||
username String? @unique
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
unverifiedNewEmail String?
|
||||
image String?
|
||||
accounts Account[]
|
||||
password String?
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
||||
</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 you’re 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 you’re 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"
|
||||
>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
|
@ -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"
|
||||
>
|
||||
|
||||
</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"
|
||||
>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
|
@ -30,13 +30,14 @@ declare global {
|
|||
EMAIL_FROM?: string;
|
||||
EMAIL_SERVER?: string;
|
||||
|
||||
BASE_URL?: string; // Used for email and stripe
|
||||
|
||||
NEXT_PUBLIC_STRIPE?: string;
|
||||
STRIPE_SECRET_KEY?: string;
|
||||
MONTHLY_PRICE_ID?: string;
|
||||
YEARLY_PRICE_ID?: string;
|
||||
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string;
|
||||
NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string;
|
||||
BASE_URL?: string;
|
||||
|
||||
// Proxy settings
|
||||
PROXY?: string;
|
||||
|
|
37
yarn.lock
37
yarn.lock
|
@ -3709,6 +3709,18 @@ graphemer@^1.4.0:
|
|||
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
|
||||
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:
|
||||
version "2.0.0"
|
||||
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"
|
||||
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:
|
||||
version "3.3.6"
|
||||
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"
|
||||
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:
|
||||
version "4.22.1"
|
||||
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"
|
||||
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:
|
||||
version "0.0.2"
|
||||
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"
|
||||
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:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
|
||||
|
@ -6218,6 +6250,11 @@ wide-align@^1.1.2:
|
|||
dependencies:
|
||||
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:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
|
|
Ŝarĝante…
Reference in New Issue