add team invitation functionality [WIP]

This commit is contained in:
daniel31x13
2024-10-21 13:59:05 -04:00
parent d146ec296c
commit cffc74caa4
32 changed files with 1083 additions and 98 deletions
+61 -48
View File
@@ -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;
}
+19 -6
View File
@@ -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,
+56
View File
@@ -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;
+27
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 = {