add team invitation functionality [WIP]
This commit is contained in:
parent
d146ec296c
commit
cffc74caa4
|
@ -35,6 +35,7 @@ READABILITY_MAX_BUFFER=
|
|||
PREVIEW_MAX_BUFFER=
|
||||
IMPORT_LIMIT=
|
||||
MAX_WORKERS=
|
||||
DISABLE_INVITES=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import TextInput from "../TextInput";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation, Trans } from "next-i18next";
|
||||
import { useAddUser } from "@/hooks/store/admin/users";
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
username?: string;
|
||||
email?: string;
|
||||
invite: boolean;
|
||||
};
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
|
||||
|
||||
export default function InviteModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addUser = useAddUser();
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
username: emailEnabled ? undefined : "",
|
||||
email: emailEnabled ? "" : undefined,
|
||||
invite: true,
|
||||
});
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!submitLoader) {
|
||||
const checkFields = () => {
|
||||
if (emailEnabled) {
|
||||
return form.email !== "";
|
||||
} else {
|
||||
return form.username !== "";
|
||||
}
|
||||
};
|
||||
|
||||
if (checkFields()) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
await addUser.mutateAsync(form, {
|
||||
onSettled: () => {
|
||||
signIn("invite", {
|
||||
email: form.email,
|
||||
callbackUrl: "/",
|
||||
redirect: false,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
} else {
|
||||
toast.error(t("fill_all_fields_error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">{t("invite_user")}</p>
|
||||
<div className="divider mb-3 mt-1"></div>
|
||||
<p className="mb-3">{t("invite_user_desc")}</p>
|
||||
<form onSubmit={submit}>
|
||||
{emailEnabled ? (
|
||||
<div>
|
||||
<TextInput
|
||||
placeholder={t("placeholder_email")}
|
||||
className="bg-base-200"
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
value={form.email}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("username")}{" "}
|
||||
{emailEnabled && (
|
||||
<span className="text-xs text-neutral">{t("optional")}</span>
|
||||
)}
|
||||
</p>
|
||||
<TextInput
|
||||
placeholder={t("placeholder_john")}
|
||||
className="bg-base-200"
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
value={form.username}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div role="note" className="alert alert-note mt-5">
|
||||
<i className="bi-exclamation-triangle text-xl" />
|
||||
<span>
|
||||
<p className="mb-1">{t("invite_user_note")}</p>
|
||||
<Link
|
||||
href=""
|
||||
className="font-semibold whitespace-nowrap hover:opacity-80 duration-100"
|
||||
target="_blank"
|
||||
>
|
||||
{t("learn_more")} <i className="bi-box-arrow-up-right"></i>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-5">
|
||||
<button
|
||||
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
|
||||
type="submit"
|
||||
>
|
||||
{t("send_invitation")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -35,6 +35,9 @@ export default function NewUserModal({ onClose }: Props) {
|
|||
event.preventDefault();
|
||||
|
||||
if (!submitLoader) {
|
||||
if (form.password.length < 8)
|
||||
return toast.error(t("password_length_error"));
|
||||
|
||||
const checkFields = () => {
|
||||
if (emailEnabled) {
|
||||
return form.name !== "" && form.email !== "" && form.password !== "";
|
||||
|
|
|
@ -12,6 +12,7 @@ export default function ProfileDropdown() {
|
|||
const { data: user = {} } = useUser();
|
||||
|
||||
const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||
const DISABLE_INVITES = process.env.DISABLE_INVITES === "true";
|
||||
|
||||
const handleToggle = () => {
|
||||
const newTheme = settings.theme === "dark" ? "light" : "dark";
|
||||
|
@ -73,6 +74,19 @@ export default function ProfileDropdown() {
|
|||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{!DISABLE_INVITES && (
|
||||
<li>
|
||||
<Link
|
||||
href="/team"
|
||||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("manage_team")}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
onClick={() => {
|
||||
|
|
|
@ -5,7 +5,7 @@ type Props = {
|
|||
src?: string;
|
||||
className?: string;
|
||||
priority?: boolean;
|
||||
name?: string;
|
||||
name?: string | null;
|
||||
large?: boolean;
|
||||
};
|
||||
|
||||
|
|
|
@ -11,9 +11,6 @@ const useUsers = () => {
|
|||
queryFn: async () => {
|
||||
const response = await fetch("/api/v1/users");
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
throw new Error("Failed to fetch users.");
|
||||
}
|
||||
|
||||
|
@ -30,8 +27,6 @@ const useAddUser = () => {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: async (body: any) => {
|
||||
if (body.password.length < 8) throw new Error(t("password_length_error"));
|
||||
|
||||
const load = toast.loading(t("creating_account"));
|
||||
|
||||
const response = await fetch("/api/v1/users", {
|
||||
|
|
|
@ -41,6 +41,7 @@ export default function AuthRedirect({ children }: Props) {
|
|||
{ path: "/tags", isProtected: true },
|
||||
{ path: "/preserved", isProtected: true },
|
||||
{ path: "/admin", isProtected: true },
|
||||
{ path: "/team", isProtected: true },
|
||||
{ path: "/search", isProtected: true },
|
||||
];
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcrypt";
|
||||
import isServerAdmin from "../../isServerAdmin";
|
||||
import { PostUserSchema } from "@/lib/shared/schemaValidation";
|
||||
import isAuthenticatedRequest from "../../isAuthenticatedRequest";
|
||||
import { Subscription, User } from "@prisma/client";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
@ -17,7 +18,11 @@ export default async function postUser(
|
|||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<Data> {
|
||||
let isAdmin = await isServerAdmin({ req });
|
||||
const parentUser = await isAuthenticatedRequest({ req });
|
||||
const isAdmin =
|
||||
parentUser && parentUser.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||
|
||||
const DISABLE_INVITES = process.env.DISABLE_INVITES === "true";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
|
||||
return { response: "Registration is disabled.", status: 400 };
|
||||
|
@ -34,15 +39,28 @@ export default async function postUser(
|
|||
};
|
||||
}
|
||||
|
||||
const { name, email, password } = dataValidation.data;
|
||||
const { name, email, password, invite } = dataValidation.data;
|
||||
let { username } = dataValidation.data;
|
||||
|
||||
if (invite && (DISABLE_INVITES || !emailEnabled)) {
|
||||
return { response: "You are not authorized to invite users.", status: 401 };
|
||||
} else if (invite && !parentUser) {
|
||||
return { response: "You must be logged in to invite users.", status: 401 };
|
||||
}
|
||||
|
||||
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
|
||||
|
||||
if (!username) {
|
||||
username = autoGeneratedUsername;
|
||||
}
|
||||
|
||||
if (!emailEnabled && !password) {
|
||||
return {
|
||||
response: "Password is required.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const checkIfUserExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
|
@ -62,62 +80,57 @@ export default async function postUser(
|
|||
|
||||
const saltRounds = 10;
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(password, saltRounds);
|
||||
const hashedPassword = bcrypt.hashSync(password || "", saltRounds);
|
||||
|
||||
// Subscription dates
|
||||
const currentPeriodStart = new Date();
|
||||
const currentPeriodEnd = new Date();
|
||||
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
|
||||
|
||||
if (isAdmin) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: name,
|
||||
username: emailEnabled
|
||||
? (username as string) || autoGeneratedUsername
|
||||
: (username as string),
|
||||
email: emailEnabled ? email : undefined,
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(),
|
||||
subscriptions: stripeEnabled
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: name,
|
||||
username: emailEnabled ? username || autoGeneratedUsername : username,
|
||||
email: emailEnabled ? email : undefined,
|
||||
emailVerified: isAdmin ? new Date() : undefined,
|
||||
password: password ? hashedPassword : undefined,
|
||||
parentSubscription:
|
||||
parentUser && invite
|
||||
? {
|
||||
connect: {
|
||||
id: (parentUser.subscriptions as Subscription).id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
subscriptions:
|
||||
stripeEnabled && isAdmin
|
||||
? {
|
||||
create: {
|
||||
stripeSubscriptionId:
|
||||
"fake_sub_" + Math.round(Math.random() * 10000000000000),
|
||||
active: true,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(
|
||||
new Date().setFullYear(new Date().getFullYear() + 1000)
|
||||
), // 1000 years from now
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
select: isAdmin
|
||||
? {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
password: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
createdAt: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return { response: user, status: 201 };
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: name,
|
||||
username: emailEnabled ? autoGeneratedUsername : (username as string),
|
||||
email: emailEnabled ? email : undefined,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: "User successfully created.", status: 201 };
|
||||
}
|
||||
const { password: pass, ...userWithoutPassword } = user as User;
|
||||
return { response: userWithoutPassword, status: 201 };
|
||||
} else {
|
||||
return { response: "Email or Username already exists.", status: 400 };
|
||||
}
|
||||
|
|
|
@ -133,7 +133,7 @@ export default async function updateUserById(
|
|||
sendChangeEmailVerificationRequest(
|
||||
user.email,
|
||||
data.email,
|
||||
data.name?.trim() || user.name
|
||||
data.name?.trim() || user.name || "Linkwarden User"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -6,16 +6,16 @@ type Props = {
|
|||
req: NextApiRequest;
|
||||
};
|
||||
|
||||
export default async function isServerAdmin({ req }: Props): Promise<boolean> {
|
||||
export default async function isAuthenticatedRequest({ req }: Props) {
|
||||
const token = await getToken({ req });
|
||||
const userId = token?.id;
|
||||
|
||||
if (!userId) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.exp < Date.now() / 1000) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// check if token is revoked
|
||||
|
@ -27,18 +27,21 @@ export default async function isServerAdmin({ req }: Props): Promise<boolean> {
|
|||
});
|
||||
|
||||
if (revoked) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const findUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (findUser?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
if (findUser && !findUser?.subscriptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findUser;
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import Stripe from "stripe";
|
||||
import verifySubscription from "./stripe/verifySubscription";
|
||||
import { prisma } from "./db";
|
||||
|
||||
export default async function paymentCheckout(
|
||||
stripeSecretKey: string,
|
||||
|
@ -9,6 +11,22 @@ export default async function paymentCheckout(
|
|||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = await verifySubscription(user);
|
||||
|
||||
if (subscription) {
|
||||
// To prevent users from creating multiple subscriptions
|
||||
return { response: "/dashboard", status: 200 };
|
||||
}
|
||||
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: email.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
|
@ -25,16 +43,11 @@ export default async function paymentCheckout(
|
|||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
adjustable_quantity: {
|
||||
enabled: true,
|
||||
minimum: 1,
|
||||
maximum: Number(process.env.STRIPE_MAX_QUANTITY || 100),
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
|
||||
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
success_url: `${process.env.BASE_URL}/dashboard`,
|
||||
cancel_url: `${process.env.BASE_URL}/login`,
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import Handlebars from "handlebars";
|
||||
import transporter from "./transporter";
|
||||
|
||||
type Params = {
|
||||
parentSubscriptionEmail: string;
|
||||
identifier: string;
|
||||
url: string;
|
||||
from: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export default async function sendInvitationRequest({
|
||||
parentSubscriptionEmail,
|
||||
identifier,
|
||||
url,
|
||||
from,
|
||||
token,
|
||||
}: Params) {
|
||||
const emailsDir = path.resolve(process.cwd(), "templates");
|
||||
|
||||
const templateFile = readFileSync(
|
||||
path.join(emailsDir, "acceptInvitation.html"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const emailTemplate = Handlebars.compile(templateFile);
|
||||
|
||||
const { host } = new URL(url);
|
||||
const result = await transporter.sendMail({
|
||||
to: identifier,
|
||||
from: {
|
||||
name: "Linkwarden",
|
||||
address: from as string,
|
||||
},
|
||||
subject: `You have been invited to join Linkwarden`,
|
||||
text: text({ url, host }),
|
||||
html: emailTemplate({
|
||||
parentSubscriptionEmail,
|
||||
identifier,
|
||||
url: `${
|
||||
process.env.NEXTAUTH_URL
|
||||
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
|
||||
}),
|
||||
});
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
if (failed.length) {
|
||||
throw new Error(`Email (${failed.join(", ")}) could not be sent`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import Stripe from "stripe";
|
||||
import { prisma } from "./db";
|
||||
import { prisma } from "../db";
|
||||
|
||||
type Data = {
|
||||
id: string;
|
|
@ -0,0 +1,27 @@
|
|||
import Stripe from "stripe";
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
const updateSeats = async (subscriptionId: string, seats: number) => {
|
||||
if (!STRIPE_SECRET_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const trialing = subscription.status === "trialing";
|
||||
|
||||
if (subscription) {
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
billing_cycle_anchor: trialing ? undefined : "now",
|
||||
proration_behavior: trialing ? undefined : "create_prorations",
|
||||
quantity: seats,
|
||||
} as Stripe.SubscriptionUpdateParams);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateSeats;
|
|
@ -1,4 +1,4 @@
|
|||
import { prisma } from "./db";
|
||||
import { prisma } from "../db";
|
||||
import { Subscription, User } from "@prisma/client";
|
||||
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
|
||||
|
||||
|
@ -7,7 +7,7 @@ interface UserIncludingSubscription extends User {
|
|||
}
|
||||
|
||||
export default async function verifySubscription(
|
||||
user?: UserIncludingSubscription
|
||||
user?: UserIncludingSubscription | null
|
||||
) {
|
||||
if (!user || !user.subscriptions) {
|
||||
return null;
|
|
@ -1,6 +1,6 @@
|
|||
import { prisma } from "./db";
|
||||
import { User } from "@prisma/client";
|
||||
import verifySubscription from "./verifySubscription";
|
||||
import verifySubscription from "./stripe/verifySubscription";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
type Props = {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { prisma } from "./db";
|
||||
import { User } from "@prisma/client";
|
||||
import verifySubscription from "./verifySubscription";
|
||||
import verifySubscription from "./stripe/verifySubscription";
|
||||
import verifyToken from "./verifyToken";
|
||||
|
||||
type Props = {
|
||||
|
|
|
@ -33,8 +33,8 @@ export const PostUserSchema = () => {
|
|||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
return z.object({
|
||||
name: z.string().trim().min(1).max(50),
|
||||
password: z.string().min(8).max(2048),
|
||||
name: z.string().trim().min(1).max(50).optional(),
|
||||
password: z.string().min(8).max(2048).optional(),
|
||||
email: emailEnabled
|
||||
? z.string().trim().email().toLowerCase()
|
||||
: z.string().optional(),
|
||||
|
@ -47,6 +47,7 @@ export const PostUserSchema = () => {
|
|||
.min(3)
|
||||
.max(50)
|
||||
.regex(/^[a-z0-9_-]{3,50}$/),
|
||||
invite: z.boolean().optional(),
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -66,7 +67,7 @@ export const UpdateUserSchema = () => {
|
|||
.min(3)
|
||||
.max(30)
|
||||
.regex(/^[a-z0-9_-]{3,30}$/),
|
||||
image: z.string().optional(),
|
||||
image: z.string().nullish(),
|
||||
password: z.string().min(8).max(2048).optional(),
|
||||
newPassword: z.string().min(8).max(2048).optional(),
|
||||
oldPassword: z.string().min(8).max(2048).optional(),
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import sendInvitationRequest from "@/lib/api/sendInvitationRequest";
|
||||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import updateSeats from "@/lib/api/stripe/updateSeats";
|
||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { User } from "@prisma/client";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Adapter } from "next-auth/adapters";
|
||||
import NextAuth from "next-auth/next";
|
||||
|
@ -133,6 +135,7 @@ if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
|
|||
if (emailEnabled) {
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
id: "email",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
|
@ -157,6 +160,56 @@ if (emailEnabled) {
|
|||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
EmailProvider({
|
||||
id: "invite",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
const parentSubscriptionEmail = (
|
||||
await prisma.user.findFirst({
|
||||
where: {
|
||||
email: identifier,
|
||||
emailVerified: null,
|
||||
},
|
||||
include: {
|
||||
parentSubscription: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)?.parentSubscription?.user.email;
|
||||
|
||||
if (!parentSubscriptionEmail) throw Error("Invalid email.");
|
||||
|
||||
const recentVerificationRequestsCount =
|
||||
await prisma.verificationToken.count({
|
||||
where: {
|
||||
identifier,
|
||||
createdAt: {
|
||||
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (recentVerificationRequestsCount >= 4)
|
||||
throw Error("Too many requests. Please try again later.");
|
||||
|
||||
sendInvitationRequest({
|
||||
parentSubscriptionEmail,
|
||||
identifier,
|
||||
url,
|
||||
from: provider.from as string,
|
||||
token,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -1179,6 +1232,52 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account, profile, email, credentials }) {
|
||||
if (
|
||||
!(user as User).emailVerified &&
|
||||
!email?.verificationRequest
|
||||
// && (account?.provider === "email" || account?.provider === "google")
|
||||
) {
|
||||
// Email is being verified for the first time...
|
||||
console.log("Email is being verified for the first time...");
|
||||
|
||||
const parentSubscriptionId = (user as User).parentSubscriptionId;
|
||||
|
||||
if (parentSubscriptionId) {
|
||||
// Add seat request to Stripe
|
||||
const parentSubscription = await prisma.subscription.findFirst({
|
||||
where: {
|
||||
id: parentSubscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
// Count child users with verified email under a specific subscription, excluding the current user
|
||||
const verifiedChildUsersCount = await prisma.user.count({
|
||||
where: {
|
||||
parentSubscriptionId: parentSubscriptionId,
|
||||
id: {
|
||||
not: user.id as number,
|
||||
},
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
STRIPE_SECRET_KEY &&
|
||||
parentSubscription?.quantity &&
|
||||
verifiedChildUsersCount + 2 > // add current user and the admin
|
||||
parentSubscription.quantity
|
||||
) {
|
||||
// Add seat if the user count exceeds the subscription limit
|
||||
await updateSeats(
|
||||
parentSubscription.stripeSubscriptionId,
|
||||
verifiedChildUsersCount + 2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (account?.provider !== "credentials") {
|
||||
// registration via SSO can be separately disabled
|
||||
const existingUser = await prisma.account.findFirst({
|
||||
|
@ -1287,8 +1386,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||
async session({ session, token }) {
|
||||
session.user.id = token.id;
|
||||
|
||||
console.log("session", session);
|
||||
|
||||
if (STRIPE_SECRET_KEY) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
|
|
|
@ -54,7 +54,7 @@ export default async function forgotPassword(
|
|||
});
|
||||
}
|
||||
|
||||
sendPasswordResetRequest(user.email, user.name);
|
||||
sendPasswordResetRequest(user.email, user.name || "Linkwarden User");
|
||||
|
||||
return res.status(200).json({
|
||||
response: "Password reset email sent.",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
||||
import updateCustomerEmail from "@/lib/api/stripe/updateCustomerEmail";
|
||||
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import getUserById from "@/lib/api/controllers/users/userId/getUserById";
|
|||
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
|
||||
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import handleSubscription from "@/lib/api/handleSubscription";
|
||||
import handleSubscription from "@/lib/api/stripe/handleSubscription";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
@ -17,7 +17,7 @@ const buffer = (req: NextApiRequest) => {
|
|||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
resolve(Buffer.concat(chunks as any));
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
|
@ -78,7 +78,7 @@ export default async function webhook(
|
|||
case "customer.subscription.updated":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: data.status === "active",
|
||||
active: data.status === "active" || data.status === "trialing",
|
||||
quantity: data?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
|
|
|
@ -23,13 +23,14 @@ export default function Subscribe() {
|
|||
const { data: user = {} } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
const hasInactiveSubscription =
|
||||
user.id && !user.subscription?.active && stripeEnabled;
|
||||
|
||||
if (session.status === "authenticated" && !hasInactiveSubscription) {
|
||||
if (
|
||||
session.status === "authenticated" &&
|
||||
user.id &&
|
||||
user?.subscription?.active
|
||||
) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [session.status]);
|
||||
}, [session.status, user]);
|
||||
|
||||
async function submit() {
|
||||
setSubmitLoader(true);
|
||||
|
@ -40,6 +41,8 @@ export default function Subscribe() {
|
|||
const data = await res.json();
|
||||
|
||||
router.push(data.response);
|
||||
|
||||
toast.dismiss(redirectionToast);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||
import { User as U } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import UserListing from "@/components/UserListing";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type UserModal = {
|
||||
isOpen: boolean;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
export default function Admin() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: users = [] } = useUsers();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||
|
||||
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
||||
isOpen: false,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-5">
|
||||
<div className="flex sm:flex-row flex-col justify-between gap-2">
|
||||
<div className="gap-2 inline-flex items-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-neutral btn btn-square btn-sm btn-ghost"
|
||||
>
|
||||
<i className="bi-chevron-left text-xl"></i>
|
||||
</Link>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("team_management")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center relative justify-between gap-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="search-box"
|
||||
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
|
||||
>
|
||||
<i className="bi-search"></i>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="search-box"
|
||||
type="text"
|
||||
placeholder={t("search_users")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
if (users) {
|
||||
setFilteredUsers(
|
||||
users.filter((user: any) =>
|
||||
JSON.stringify(user)
|
||||
.toLowerCase()
|
||||
.includes(e.target.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setInviteModal(true)}
|
||||
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
|
||||
>
|
||||
<i className="bi-plus text-3xl absolute"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
||||
) : searchQuery !== "" ? (
|
||||
<p>{t("no_user_found_in_search")}</p>
|
||||
) : users && users.length > 0 ? (
|
||||
UserListing(users, deleteUserModal, setDeleteUserModal, t)
|
||||
) : (
|
||||
<p>{t("no_users_found")}</p>
|
||||
)}
|
||||
|
||||
{inviteModal && <InviteModal onClose={() => setInviteModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSideProps };
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `_LinkToUser` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TeamRole" AS ENUM ('MEMBER', 'ADMIN');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_LinkToUser" DROP CONSTRAINT "_LinkToUser_A_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_LinkToUser" DROP CONSTRAINT "_LinkToUser_B_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Collection" ADD COLUMN "createdById" INTEGER,
|
||||
ADD COLUMN "teamId" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Link" ADD COLUMN "createdById" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "parentSubscriptionId" INTEGER,
|
||||
ADD COLUMN "teamRole" "TeamRole" NOT NULL DEFAULT 'ADMIN',
|
||||
ALTER COLUMN "name" DROP NOT NULL;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "_LinkToUser";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_PinnedLinks" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_PinnedLinks_AB_unique" ON "_PinnedLinks"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_PinnedLinks_B_index" ON "_PinnedLinks"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_parentSubscriptionId_fkey" FOREIGN KEY ("parentSubscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Link" ADD CONSTRAINT "Link_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_PinnedLinks" ADD CONSTRAINT "_PinnedLinks_A_fkey" FOREIGN KEY ("A") REFERENCES "Link"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_PinnedLinks" ADD CONSTRAINT "_PinnedLinks_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -27,7 +27,7 @@ model Account {
|
|||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
name String?
|
||||
username String? @unique
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
|
@ -35,10 +35,15 @@ model User {
|
|||
image String?
|
||||
password String?
|
||||
locale String @default("en")
|
||||
parentSubscription Subscription? @relation("ChildUsers", fields: [parentSubscriptionId], references: [id])
|
||||
parentSubscriptionId Int?
|
||||
teamRole TeamRole @default(ADMIN)
|
||||
accounts Account[]
|
||||
collections Collection[]
|
||||
tags Tag[]
|
||||
pinnedLinks Link[]
|
||||
pinnedLinks Link[] @relation("PinnedLinks")
|
||||
createdLinks Link[] @relation("CreatedLinks")
|
||||
createdCollections Collection[] @relation("CreatedCollections")
|
||||
collectionsJoined UsersAndCollections[]
|
||||
collectionOrder Int[] @default([])
|
||||
whitelistedUsers WhitelistedUser[]
|
||||
|
@ -72,6 +77,11 @@ model WhitelistedUser {
|
|||
updatedAt DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
enum TeamRole {
|
||||
MEMBER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
|
@ -104,6 +114,9 @@ model Collection {
|
|||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId Int
|
||||
members UsersAndCollections[]
|
||||
teamId Int?
|
||||
createdBy User? @relation("CreatedCollections", fields: [createdById], references: [id])
|
||||
createdById Int?
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
@ -131,7 +144,9 @@ model Link {
|
|||
name String @default("")
|
||||
type String @default("url")
|
||||
description String @default("")
|
||||
pinnedBy User[]
|
||||
pinnedBy User[] @relation("PinnedLinks")
|
||||
createdBy User? @relation("CreatedLinks", fields: [createdById], references: [id])
|
||||
createdById Int?
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
collectionId Int
|
||||
tags Tag[]
|
||||
|
@ -173,6 +188,7 @@ model Subscription {
|
|||
quantity Int @default(1)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int @unique
|
||||
childUsers User[] @relation("ChildUsers")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
|
|
@ -396,5 +396,12 @@
|
|||
"default": "Default",
|
||||
"invalid_url_guide":"Please enter a valid Address for the Link. (It should start with http/https)",
|
||||
"email_invalid": "Please enter a valid email address.",
|
||||
"username_invalid_guide": "Username has to be at least 3 characters, no spaces and special characters are allowed."
|
||||
"username_invalid_guide": "Username has to be at least 3 characters, no spaces and special characters are allowed.",
|
||||
"manage_team": "Manage Team",
|
||||
"team_management": "Team Management",
|
||||
"invite_user": "Invite User",
|
||||
"invite_user_desc": "To invite someone to your team, please enter their email address below:",
|
||||
"invite_user_note": "Please note that once the invitation is accepted, an additional seat will be used and your account will automatically be billed for this addition.",
|
||||
"send_invitation": "Send Invitation",
|
||||
"learn_more": "Learn more"
|
||||
}
|
|
@ -142,13 +142,13 @@ function delay(sec: number) {
|
|||
}
|
||||
|
||||
async function init() {
|
||||
console.log("\x1b[34m%s\x1b[0m", "Starting the link processing task");
|
||||
console.log("\x1b[34m%s\x1b[0m", "Processing the links...");
|
||||
while (true) {
|
||||
try {
|
||||
await processBatch();
|
||||
await delay(intervalInSeconds);
|
||||
} catch (error) {
|
||||
console.error("\x1b[34m%s\x1b[0m", "Error processing links:", error);
|
||||
console.error("\x1b[34m%s\x1b[0m", "Error processing link:", error);
|
||||
await delay(intervalInSeconds);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,445 @@
|
|||
<!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;
|
||||
"
|
||||
>
|
||||
Dear
|
||||
<a
|
||||
href="mailto:{{parentSubscriptionEmail}}"
|
||||
style="color: red"
|
||||
>{{identifier}}</a
|
||||
>,
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style="
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
You have been invited to join Linkwarden by
|
||||
<a
|
||||
href="mailto:{{parentSubscriptionEmail}}"
|
||||
style="color: red"
|
||||
>
|
||||
{{parentSubscriptionEmail}}</a
|
||||
>!
|
||||
</p>
|
||||
|
||||
<p
|
||||
style="
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
Linkwarden simplifies digital content management by allowing
|
||||
teams and individuals to easily collect, organize, and
|
||||
preserve webpages and articles.
|
||||
</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;
|
||||
"
|
||||
>
|
||||
Accept Invitation
|
||||
</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;
|
||||
"
|
||||
>
|
||||
Please note that your Linkwarden account and billing will be
|
||||
managed by
|
||||
<a
|
||||
href="mailto:{{parentSubscriptionEmail}}"
|
||||
style="color: red"
|
||||
>
|
||||
{{parentSubscriptionEmail}}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
<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>
|
Ŝarĝante…
Reference in New Issue