From cffc74caa484416b37988bc8616a91e8b780c80a Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 21 Oct 2024 13:59:05 -0400 Subject: [PATCH] add team invitation functionality [WIP] --- .env.sample | 1 + components/ModalContent/InviteModal.tsx | 126 +++++ components/ModalContent/NewUserModal.tsx | 3 + components/ProfileDropdown.tsx | 14 + components/ProfilePhoto.tsx | 2 +- hooks/store/admin/users.tsx | 5 - layouts/AuthRedirect.tsx | 1 + lib/api/controllers/users/postUser.ts | 109 +++-- .../users/userId/updateUserById.ts | 2 +- ...rverAdmin.ts => isAuthenticatedRequest.ts} | 19 +- lib/api/paymentCheckout.ts | 25 +- lib/api/sendInvitationRequest.ts | 56 +++ .../{ => stripe}/checkSubscriptionByEmail.ts | 0 lib/api/{ => stripe}/handleSubscription.ts | 2 +- lib/api/{ => stripe}/updateCustomerEmail.ts | 0 lib/api/stripe/updateSeats.ts | 27 ++ lib/api/{ => stripe}/verifySubscription.ts | 4 +- lib/api/verifyByCredentials.ts | 2 +- lib/api/verifyUser.ts | 2 +- lib/shared/schemaValidation.ts | 7 +- pages/api/v1/auth/[...nextauth].ts | 105 ++++- pages/api/v1/auth/forgot-password.ts | 2 +- pages/api/v1/auth/verify-email.ts | 2 +- pages/api/v1/users/[id].ts | 2 +- pages/api/v1/webhook/index.ts | 6 +- pages/subscribe.tsx | 13 +- pages/team.tsx | 108 +++++ .../migration.sql | 56 +++ prisma/schema.prisma | 22 +- public/locales/en/common.json | 9 +- scripts/worker.ts | 4 +- templates/acceptInvitation.html | 445 ++++++++++++++++++ 32 files changed, 1083 insertions(+), 98 deletions(-) create mode 100644 components/ModalContent/InviteModal.tsx rename lib/api/{isServerAdmin.ts => isAuthenticatedRequest.ts} (69%) create mode 100644 lib/api/sendInvitationRequest.ts rename lib/api/{ => stripe}/checkSubscriptionByEmail.ts (100%) rename lib/api/{ => stripe}/handleSubscription.ts (98%) rename lib/api/{ => stripe}/updateCustomerEmail.ts (100%) create mode 100644 lib/api/stripe/updateSeats.ts rename lib/api/{ => stripe}/verifySubscription.ts (95%) create mode 100644 pages/team.tsx create mode 100644 prisma/migrations/20241021175802_add_child_subscription_support/migration.sql create mode 100644 templates/acceptInvitation.html diff --git a/.env.sample b/.env.sample index f6b4d41..5fbac1f 100644 --- a/.env.sample +++ b/.env.sample @@ -35,6 +35,7 @@ READABILITY_MAX_BUFFER= PREVIEW_MAX_BUFFER= IMPORT_LIMIT= MAX_WORKERS= +DISABLE_INVITES= # AWS S3 Settings SPACES_KEY= diff --git a/components/ModalContent/InviteModal.tsx b/components/ModalContent/InviteModal.tsx new file mode 100644 index 0000000..0af71d5 --- /dev/null +++ b/components/ModalContent/InviteModal.tsx @@ -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({ + username: emailEnabled ? undefined : "", + email: emailEnabled ? "" : undefined, + invite: true, + }); + const [submitLoader, setSubmitLoader] = useState(false); + + async function submit(event: FormEvent) { + 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 ( + +

{t("invite_user")}

+
+

{t("invite_user_desc")}

+
+ {emailEnabled ? ( +
+ setForm({ ...form, email: e.target.value })} + value={form.email} + /> +
+ ) : ( +
+

+ {t("username")}{" "} + {emailEnabled && ( + {t("optional")} + )} +

+ setForm({ ...form, username: e.target.value })} + value={form.username} + /> +
+ )} + +
+ + +

{t("invite_user_note")}

+ + {t("learn_more")} + +
+
+ +
+ +
+ +
+ ); +} diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx index 1b4fb6d..ac8b4dc 100644 --- a/components/ModalContent/NewUserModal.tsx +++ b/components/ModalContent/NewUserModal.tsx @@ -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 !== ""; diff --git a/components/ProfileDropdown.tsx b/components/ProfileDropdown.tsx index 80d5119..acab61e 100644 --- a/components/ProfileDropdown.tsx +++ b/components/ProfileDropdown.tsx @@ -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() { )} + {!DISABLE_INVITES && ( +
  • + (document?.activeElement as HTMLElement)?.blur()} + tabIndex={0} + role="button" + className="whitespace-nowrap" + > + {t("manage_team")} + +
  • + )}
  • { diff --git a/components/ProfilePhoto.tsx b/components/ProfilePhoto.tsx index e34b0ce..753901f 100644 --- a/components/ProfilePhoto.tsx +++ b/components/ProfilePhoto.tsx @@ -5,7 +5,7 @@ type Props = { src?: string; className?: string; priority?: boolean; - name?: string; + name?: string | null; large?: boolean; }; diff --git a/hooks/store/admin/users.tsx b/hooks/store/admin/users.tsx index 476beb3..1c6d475 100644 --- a/hooks/store/admin/users.tsx +++ b/hooks/store/admin/users.tsx @@ -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", { diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index c3a6a3d..2ec9899 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -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 }, ]; diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts index 0f82495..f77e3c0 100644 --- a/lib/api/controllers/users/postUser.ts +++ b/lib/api/controllers/users/postUser.ts @@ -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 { - 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 }; } diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index c62a594..7c92585 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -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" ); } diff --git a/lib/api/isServerAdmin.ts b/lib/api/isAuthenticatedRequest.ts similarity index 69% rename from lib/api/isServerAdmin.ts rename to lib/api/isAuthenticatedRequest.ts index d34cc07..eb29e31 100644 --- a/lib/api/isServerAdmin.ts +++ b/lib/api/isAuthenticatedRequest.ts @@ -6,16 +6,16 @@ type Props = { req: NextApiRequest; }; -export default async function isServerAdmin({ req }: Props): Promise { +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 { }); 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; } diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts index a76571a..0a89ea6 100644 --- a/lib/api/paymentCheckout.ts +++ b/lib/api/paymentCheckout.ts @@ -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, diff --git a/lib/api/sendInvitationRequest.ts b/lib/api/sendInvitationRequest.ts new file mode 100644 index 0000000..be59238 --- /dev/null +++ b/lib/api/sendInvitationRequest.ts @@ -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`; +} diff --git a/lib/api/checkSubscriptionByEmail.ts b/lib/api/stripe/checkSubscriptionByEmail.ts similarity index 100% rename from lib/api/checkSubscriptionByEmail.ts rename to lib/api/stripe/checkSubscriptionByEmail.ts diff --git a/lib/api/handleSubscription.ts b/lib/api/stripe/handleSubscription.ts similarity index 98% rename from lib/api/handleSubscription.ts rename to lib/api/stripe/handleSubscription.ts index 6ccaaf3..8f41e2e 100644 --- a/lib/api/handleSubscription.ts +++ b/lib/api/stripe/handleSubscription.ts @@ -1,5 +1,5 @@ import Stripe from "stripe"; -import { prisma } from "./db"; +import { prisma } from "../db"; type Data = { id: string; diff --git a/lib/api/updateCustomerEmail.ts b/lib/api/stripe/updateCustomerEmail.ts similarity index 100% rename from lib/api/updateCustomerEmail.ts rename to lib/api/stripe/updateCustomerEmail.ts diff --git a/lib/api/stripe/updateSeats.ts b/lib/api/stripe/updateSeats.ts new file mode 100644 index 0000000..bf061fc --- /dev/null +++ b/lib/api/stripe/updateSeats.ts @@ -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; diff --git a/lib/api/verifySubscription.ts b/lib/api/stripe/verifySubscription.ts similarity index 95% rename from lib/api/verifySubscription.ts rename to lib/api/stripe/verifySubscription.ts index 6fd5aab..d4b756d 100644 --- a/lib/api/verifySubscription.ts +++ b/lib/api/stripe/verifySubscription.ts @@ -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; diff --git a/lib/api/verifyByCredentials.ts b/lib/api/verifyByCredentials.ts index a0bbba2..48baf5b 100644 --- a/lib/api/verifyByCredentials.ts +++ b/lib/api/verifyByCredentials.ts @@ -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 = { diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index d77a74f..66017e4 100644 --- a/lib/api/verifyUser.ts +++ b/lib/api/verifyUser.ts @@ -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 = { diff --git a/lib/shared/schemaValidation.ts b/lib/shared/schemaValidation.ts index 60c3c1f..30398c0 100644 --- a/lib/shared/schemaValidation.ts +++ b/lib/shared/schemaValidation.ts @@ -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(), diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 51cbe2a..cd9f585 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -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: { diff --git a/pages/api/v1/auth/forgot-password.ts b/pages/api/v1/auth/forgot-password.ts index 9c1da76..927778e 100644 --- a/pages/api/v1/auth/forgot-password.ts +++ b/pages/api/v1/auth/forgot-password.ts @@ -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.", diff --git a/pages/api/v1/auth/verify-email.ts b/pages/api/v1/auth/verify-email.ts index 141ae3d..e649f71 100644 --- a/pages/api/v1/auth/verify-email.ts +++ b/pages/api/v1/auth/verify-email.ts @@ -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"; diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index 5213d7f..e37fe41 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -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; diff --git a/pages/api/v1/webhook/index.ts b/pages/api/v1/webhook/index.ts index 2635fda..c2e9ef7 100644 --- a/pages/api/v1/webhook/index.ts +++ b/pages/api/v1/webhook/index.ts @@ -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, diff --git a/pages/subscribe.tsx b/pages/subscribe.tsx index 555798c..f4f6844 100644 --- a/pages/subscribe.tsx +++ b/pages/subscribe.tsx @@ -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 ( diff --git a/pages/team.tsx b/pages/team.tsx new file mode 100644 index 0000000..6f81842 --- /dev/null +++ b/pages/team.tsx @@ -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(); + + const [deleteUserModal, setDeleteUserModal] = useState({ + isOpen: false, + userId: null, + }); + + const [inviteModal, setInviteModal] = useState(false); + + return ( +
    +
    +
    + + + +

    + {t("team_management")} +

    +
    + +
    +
    + + + { + 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" + /> +
    + +
    setInviteModal(true)} + className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative" + > + +
    +
    +
    + +
    + + {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? ( + UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t) + ) : searchQuery !== "" ? ( +

    {t("no_user_found_in_search")}

    + ) : users && users.length > 0 ? ( + UserListing(users, deleteUserModal, setDeleteUserModal, t) + ) : ( +

    {t("no_users_found")}

    + )} + + {inviteModal && setInviteModal(false)} />} +
    + ); +} + +export { getServerSideProps }; diff --git a/prisma/migrations/20241021175802_add_child_subscription_support/migration.sql b/prisma/migrations/20241021175802_add_child_subscription_support/migration.sql new file mode 100644 index 0000000..e56043b --- /dev/null +++ b/prisma/migrations/20241021175802_add_child_subscription_support/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85a80e2..ce4ca12 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index f717bf7..93f772c 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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" } \ No newline at end of file diff --git a/scripts/worker.ts b/scripts/worker.ts index ca7a6ca..81bd53e 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -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); } } diff --git a/templates/acceptInvitation.html b/templates/acceptInvitation.html new file mode 100644 index 0000000..a77f6fa --- /dev/null +++ b/templates/acceptInvitation.html @@ -0,0 +1,445 @@ + + + + + + Email + + + + + + + + + + + +