From d042c82cb034389fcccac103796bb19e96427c6e Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sun, 6 Oct 2024 01:59:31 -0400 Subject: [PATCH 01/11] add subscription webhook --- lib/api/controllers/users/getUsers.ts | 2 +- lib/api/handleSubscription.ts | 91 +++++++++++ lib/api/paymentCheckout.ts | 6 + pages/api/v1/webhook/index.ts | 143 ++++++++++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 lib/api/handleSubscription.ts create mode 100644 pages/api/v1/webhook/index.ts create mode 100644 prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts index 496efcf..dd589c5 100644 --- a/lib/api/controllers/users/getUsers.ts +++ b/lib/api/controllers/users/getUsers.ts @@ -17,5 +17,5 @@ export default async function getUsers() { }, }); - return { response: users, status: 200 }; + return { response: users.sort((a: any, b: any) => a.id - b.id), status: 200 }; } diff --git a/lib/api/handleSubscription.ts b/lib/api/handleSubscription.ts new file mode 100644 index 0000000..6ccaaf3 --- /dev/null +++ b/lib/api/handleSubscription.ts @@ -0,0 +1,91 @@ +import Stripe from "stripe"; +import { prisma } from "./db"; + +type Data = { + id: string; + active: boolean; + quantity: number; + periodStart: number; + periodEnd: number; +}; + +export default async function handleSubscription({ + id, + active, + quantity, + periodStart, + periodEnd, +}: Data) { + const subscription = await prisma.subscription.findUnique({ + where: { + stripeSubscriptionId: id, + }, + }); + + if (subscription) { + await prisma.subscription.update({ + where: { + stripeSubscriptionId: id, + }, + data: { + active, + quantity, + currentPeriodStart: new Date(periodStart * 1000), + currentPeriodEnd: new Date(periodEnd * 1000), + }, + }); + return; + } else { + if (!process.env.STRIPE_SECRET_KEY) + throw new Error("Missing Stripe secret key"); + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + + const subscription = await stripe.subscriptions.retrieve(id); + const customerId = subscription.customer; + + const customer = await stripe.customers.retrieve(customerId.toString()); + const email = (customer as Stripe.Customer).email; + + if (!email) throw new Error("Email not found"); + + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + + if (!user) throw new Error("User not found"); + + const userId = user.id; + + await prisma.subscription + .upsert({ + where: { + userId, + }, + create: { + active, + stripeSubscriptionId: id, + quantity, + currentPeriodStart: new Date(periodStart * 1000), + currentPeriodEnd: new Date(periodEnd * 1000), + user: { + connect: { + id: userId, + }, + }, + }, + update: { + active, + stripeSubscriptionId: id, + quantity, + currentPeriodStart: new Date(periodStart * 1000), + currentPeriodEnd: new Date(periodEnd * 1000), + }, + }) + .catch((err) => console.log(err)); + } +} diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts index bae7f1d..a76571a 100644 --- a/lib/api/paymentCheckout.ts +++ b/lib/api/paymentCheckout.ts @@ -18,12 +18,18 @@ export default async function paymentCheckout( const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; + const session = await stripe.checkout.sessions.create({ customer: isExistingCustomer ? isExistingCustomer : undefined, line_items: [ { price: priceId, quantity: 1, + adjustable_quantity: { + enabled: true, + minimum: 1, + maximum: Number(process.env.STRIPE_MAX_QUANTITY || 100), + }, }, ], mode: "subscription", diff --git a/pages/api/v1/webhook/index.ts b/pages/api/v1/webhook/index.ts new file mode 100644 index 0000000..a266af3 --- /dev/null +++ b/pages/api/v1/webhook/index.ts @@ -0,0 +1,143 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import Stripe from "stripe"; +import handleSubscription from "@/lib/api/handleSubscription"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +const buffer = (req: NextApiRequest) => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + req.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + req.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + + req.on("error", reject); + }); +}; + +export default async function webhook( + req: NextApiRequest, + res: NextApiResponse +) { + if (process.env.NEXT_PUBLIC_DEMO === "true") + return res.status(400).json({ + response: + "This action is disabled because this is a read-only demo of Linkwarden.", + }); + + // see if stripe is already initialized + if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) { + return res.status(400).json({ + response: "This action is disabled because Stripe is not initialized.", + }); + } + + let event = req.body; + + const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + + const signature = req.headers["stripe-signature"] as any; + + try { + const body = await buffer(req); + event = stripe.webhooks.constructEvent(body, signature, endpointSecret); + } catch (err) { + console.error(err); + return res.status(400).send("Webhook signature verification failed."); + } + + // switch (event.type) { + // case "invoice.payment_succeeded": + // console.log( + // `Payment for ${event.data.object.customer_email} was successful!` + // ); + // await handleCreateSubscription(event.data.object); + // break; + // case "customer.subscription.updated": + // console.log( + // `Subscription for ${event.data.object.customer_email} was updated.` + // ); + // await handleUpdateSubscription(event.data.object); + // break; + // case "customer.subscription.deleted": + // console.log( + // `Subscription for ${event.data.object.customer_email} was deleted.` + // ); + // await handleDeleteSubscription(event.data.object); + // break; + // default: + // // Unexpected event type + // console.log(`Unhandled event type ${event.type}.`); + // } + + // Handle the event based on its type + const eventType = event.type; + const data = event.data.object; + + try { + switch (eventType) { + case "customer.subscription.created": + await handleSubscription({ + id: data.id, + active: data.status === "active" || data.status === "trialing", + quantity: data?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + case "customer.subscription.updated": + await handleSubscription({ + id: data.id, + active: data.status === "active", + quantity: data?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + case "customer.subscription.deleted": + await handleSubscription({ + id: data.id, + active: false, + quantity: data?.lines?.data[0]?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + case "customer.subscription.cancelled": + await handleSubscription({ + id: data.id, + active: !(data.current_period_end * 1000 < Date.now()), + quantity: data?.lines?.data[0]?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + default: + console.log(`Unhandled event type ${eventType}`); + } + } catch (error) { + console.error("Error handling webhook event:", error); + return res.status(500).send("Server Error"); + } + + return res.status(200).json({ + response: "Done!", + }); +} diff --git a/prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql b/prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql new file mode 100644 index 0000000..051b61f --- /dev/null +++ b/prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 922480f..85a80e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -170,6 +170,7 @@ model Subscription { stripeSubscriptionId String @unique currentPeriodStart DateTime currentPeriodEnd DateTime + quantity Int @default(1) user User @relation(fields: [userId], references: [id]) userId Int @unique createdAt DateTime @default(now()) From f68582e28c14bb3f6906d4a8f31b24bd1738dbb5 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 7 Oct 2024 00:57:36 -0400 Subject: [PATCH 02/11] bug fixed --- lib/api/checkSubscriptionByEmail.ts | 50 +++++------------- lib/api/verifySubscription.ts | 80 +++++++++++++---------------- pages/api/v1/webhook/index.ts | 24 --------- 3 files changed, 50 insertions(+), 104 deletions(-) diff --git a/lib/api/checkSubscriptionByEmail.ts b/lib/api/checkSubscriptionByEmail.ts index 62a2320..1df51a7 100644 --- a/lib/api/checkSubscriptionByEmail.ts +++ b/lib/api/checkSubscriptionByEmail.ts @@ -1,22 +1,9 @@ import Stripe from "stripe"; -const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID; -const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; export default async function checkSubscriptionByEmail(email: string) { - let active: boolean | undefined, - stripeSubscriptionId: string | undefined, - currentPeriodStart: number | undefined, - currentPeriodEnd: number | undefined; - - if (!STRIPE_SECRET_KEY) - return { - active, - stripeSubscriptionId, - currentPeriodStart, - currentPeriodEnd, - }; + if (!STRIPE_SECRET_KEY) return null; const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2022-11-15", @@ -28,26 +15,17 @@ export default async function checkSubscriptionByEmail(email: string) { expand: ["data.subscriptions"], }); - listByEmail.data.some((customer) => { - customer.subscriptions?.data.some((subscription) => { - subscription.current_period_end; - - active = - subscription.items.data.some( - (e) => - (e.price.id === MONTHLY_PRICE_ID && e.price.active === true) || - (e.price.id === YEARLY_PRICE_ID && e.price.active === true) - ) || false; - stripeSubscriptionId = subscription.id; - currentPeriodStart = subscription.current_period_start * 1000; - currentPeriodEnd = subscription.current_period_end * 1000; - }); - }); - - return { - active: active || false, - stripeSubscriptionId, - currentPeriodStart, - currentPeriodEnd, - }; + if (listByEmail?.data[0]?.subscriptions?.data[0]) { + return { + active: (listByEmail.data[0].subscriptions?.data[0] as any).plan.active, + stripeSubscriptionId: listByEmail.data[0].subscriptions?.data[0].id, + currentPeriodStart: + listByEmail.data[0].subscriptions?.data[0].current_period_start * 1000, + currentPeriodEnd: + listByEmail.data[0].subscriptions?.data[0].current_period_end * 1000, + quantity: (listByEmail?.data[0]?.subscriptions?.data[0] as any).quantity, + }; + } else { + return null; + } } diff --git a/lib/api/verifySubscription.ts b/lib/api/verifySubscription.ts index b153cc6..6fd5aab 100644 --- a/lib/api/verifySubscription.ts +++ b/lib/api/verifySubscription.ts @@ -9,64 +9,56 @@ interface UserIncludingSubscription extends User { export default async function verifySubscription( user?: UserIncludingSubscription ) { - if (!user) { + if (!user || !user.subscriptions) { return null; } - const subscription = user.subscriptions; + if ( + !user.subscriptions.active || + new Date() > user.subscriptions.currentPeriodEnd + ) { + const subscription = await checkSubscriptionByEmail(user.email as string); - const currentDate = new Date(); + if ( + !subscription || + !subscription.stripeSubscriptionId || + !subscription.currentPeriodEnd || + !subscription.currentPeriodStart || + !subscription.quantity + ) { + return null; + } - if (!subscription?.active || currentDate > subscription.currentPeriodEnd) { const { active, stripeSubscriptionId, currentPeriodStart, currentPeriodEnd, - } = await checkSubscriptionByEmail(user.email as string); + quantity, + } = subscription; - if ( - active && - stripeSubscriptionId && - currentPeriodStart && - currentPeriodEnd - ) { - await prisma.subscription - .upsert({ - where: { - userId: user.id, - }, - create: { - active, - stripeSubscriptionId, - currentPeriodStart: new Date(currentPeriodStart), - currentPeriodEnd: new Date(currentPeriodEnd), - userId: user.id, - }, - update: { - active, - stripeSubscriptionId, - currentPeriodStart: new Date(currentPeriodStart), - currentPeriodEnd: new Date(currentPeriodEnd), - }, - }) - .catch((err) => console.log(err)); - } else if (!active) { - const subscription = await prisma.subscription.findFirst({ + await prisma.subscription + .upsert({ where: { userId: user.id, }, - }); - - if (subscription) - await prisma.subscription.delete({ - where: { - userId: user.id, - }, - }); - - return null; - } + create: { + active, + stripeSubscriptionId, + currentPeriodStart: new Date(currentPeriodStart), + currentPeriodEnd: new Date(currentPeriodEnd), + quantity, + userId: user.id, + }, + update: { + active, + stripeSubscriptionId, + currentPeriodStart: new Date(currentPeriodStart), + currentPeriodEnd: new Date(currentPeriodEnd), + quantity, + }, + }) + .catch((err) => console.log(err)); } return user; diff --git a/pages/api/v1/webhook/index.ts b/pages/api/v1/webhook/index.ts index a266af3..2635fda 100644 --- a/pages/api/v1/webhook/index.ts +++ b/pages/api/v1/webhook/index.ts @@ -59,30 +59,6 @@ export default async function webhook( return res.status(400).send("Webhook signature verification failed."); } - // switch (event.type) { - // case "invoice.payment_succeeded": - // console.log( - // `Payment for ${event.data.object.customer_email} was successful!` - // ); - // await handleCreateSubscription(event.data.object); - // break; - // case "customer.subscription.updated": - // console.log( - // `Subscription for ${event.data.object.customer_email} was updated.` - // ); - // await handleUpdateSubscription(event.data.object); - // break; - // case "customer.subscription.deleted": - // console.log( - // `Subscription for ${event.data.object.customer_email} was deleted.` - // ); - // await handleDeleteSubscription(event.data.object); - // break; - // default: - // // Unexpected event type - // console.log(`Unhandled event type ${event.type}.`); - // } - // Handle the event based on its type const eventType = event.type; const data = event.data.object; From d146ec296cd9b5d9aff290adff53c682673cc40f Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 7 Oct 2024 23:43:44 -0400 Subject: [PATCH 03/11] bug fixed --- .../collections/collectionId/updateCollectionById.ts | 6 +++--- lib/shared/schemaValidation.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/api/controllers/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts index 569090a..0319a65 100644 --- a/lib/api/controllers/collections/collectionId/updateCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/updateCollectionById.ts @@ -35,7 +35,7 @@ export default async function updateCollection( return { response: "Collection is not accessible.", status: 401 }; if (data.parentId) { - if (data.parentId !== ("root" as any)) { + if (data.parentId !== "root") { const findParentCollection = await prisma.collection.findUnique({ where: { id: data.parentId, @@ -79,13 +79,13 @@ export default async function updateCollection( iconWeight: data.iconWeight, isPublic: data.isPublic, parent: - data.parentId && data.parentId !== ("root" as any) + data.parentId && data.parentId !== "root" ? { connect: { id: data.parentId, }, } - : data.parentId === ("root" as any) + : data.parentId === "root" ? { disconnect: true, } diff --git a/lib/shared/schemaValidation.ts b/lib/shared/schemaValidation.ts index 955cfa8..60c3c1f 100644 --- a/lib/shared/schemaValidation.ts +++ b/lib/shared/schemaValidation.ts @@ -189,7 +189,7 @@ export const UpdateCollectionSchema = z.object({ isPublic: z.boolean().optional(), icon: z.string().trim().max(50).nullish(), iconWeight: z.string().trim().max(50).nullish(), - parentId: z.number().nullish(), + parentId: z.union([z.number(), z.literal("root")]).nullish(), members: z.array( z.object({ userId: z.number(), From cffc74caa484416b37988bc8616a91e8b780c80a Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 21 Oct 2024 13:59:05 -0400 Subject: [PATCH 04/11] 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 + + + + + + + + + + + + From d3d2d5069ea21b5e438fb4e6eb88bffbdc925405 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 26 Oct 2024 09:42:21 -0400 Subject: [PATCH 05/11] add member onboarding --- components/ModalContent/InviteModal.tsx | 2 +- layouts/AuthRedirect.tsx | 7 +- .../controllers/users/userId/getUserById.ts | 14 +- .../users/userId/updateUserById.ts | 43 ++++- lib/api/paymentCheckout.ts | 1 + lib/api/stripe/verifySubscription.ts | 9 +- lib/api/verifyByCredentials.ts | 1 + lib/api/verifyUser.ts | 1 + pages/api/v1/auth/[...nextauth].ts | 1 + pages/api/v1/users/[id].ts | 1 + pages/member-onboarding.tsx | 158 ++++++++++++++++++ pages/subscribe.tsx | 6 +- public/locales/en/common.json | 5 +- 13 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 pages/member-onboarding.tsx diff --git a/components/ModalContent/InviteModal.tsx b/components/ModalContent/InviteModal.tsx index 0af71d5..7f4ee09 100644 --- a/components/ModalContent/InviteModal.tsx +++ b/components/ModalContent/InviteModal.tsx @@ -50,7 +50,7 @@ export default function InviteModal({ onClose }: Props) { onSettled: () => { signIn("invite", { email: form.email, - callbackUrl: "/", + callbackUrl: "/member-onboarding", redirect: false, }); }, diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index 2ec9899..ad1d0be 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -23,7 +23,10 @@ export default function AuthRedirect({ children }: Props) { const isUnauthenticated = status === "unauthenticated"; const isPublicPage = router.pathname.startsWith("/public"); const hasInactiveSubscription = - user.id && !user.subscription?.active && stripeEnabled; + user.id && + !user.subscription?.active && + !user.parentSubscription?.active && + stripeEnabled; // There are better ways of doing this... but this one works for now const routes = [ @@ -50,6 +53,8 @@ export default function AuthRedirect({ children }: Props) { } else { if (isLoggedIn && hasInactiveSubscription) { redirectTo("/subscribe"); + } else if (isLoggedIn && !user.name && user.parentSubscriptionId) { + redirectTo("/member-onboarding"); } else if ( isLoggedIn && !routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected) diff --git a/lib/api/controllers/users/userId/getUserById.ts b/lib/api/controllers/users/userId/getUserById.ts index 8c6faa8..f704155 100644 --- a/lib/api/controllers/users/userId/getUserById.ts +++ b/lib/api/controllers/users/userId/getUserById.ts @@ -12,6 +12,11 @@ export default async function getUserById(userId: number) { }, }, subscriptions: true, + parentSubscription: { + include: { + user: true, + }, + }, }, }); @@ -22,7 +27,8 @@ export default async function getUserById(userId: number) { (usernames) => usernames.username ); - const { password, subscriptions, ...lessSensitiveInfo } = user; + const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } = + user; const data = { ...lessSensitiveInfo, @@ -30,6 +36,12 @@ export default async function getUserById(userId: number) { subscription: { active: subscriptions?.active, }, + parentSubscription: { + active: parentSubscription?.active, + user: { + email: parentSubscription?.user.email, + }, + }, }; return { response: data, status: 200 }; diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 7c92585..49e5c4c 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -101,7 +101,6 @@ export default async function updateUserById( const user = await prisma.user.findUnique({ where: { id: userId }, - select: { email: true, password: true, name: true }, }); if (user && user.email && data.email && data.email !== user.email) { @@ -170,8 +169,20 @@ export default async function updateUserById( // Other settings / Apply changes + const isInvited = + user?.name === null && user.parentSubscriptionId && !user.password; + + if (isInvited && data.password === "") + return { + response: "Password is required.", + status: 400, + }; + const saltRounds = 10; - const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds); + const newHashedPassword = bcrypt.hashSync( + data.newPassword || data.password || "", + saltRounds + ); const updatedUser = await prisma.user.update({ where: { @@ -198,18 +209,28 @@ export default async function updateUserById( linksRouteTo: data.linksRouteTo, preventDuplicateLinks: data.preventDuplicateLinks, password: - data.newPassword && data.newPassword !== "" + isInvited || (data.newPassword && data.newPassword !== "") ? newHashedPassword : undefined, }, include: { whitelistedUsers: true, subscriptions: true, + parentSubscription: { + include: { + user: true, + }, + }, }, }); - const { whitelistedUsers, password, subscriptions, ...userInfo } = - updatedUser; + const { + whitelistedUsers, + password, + subscriptions, + parentSubscription, + ...userInfo + } = updatedUser; // If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed const newWhitelistedUsernames: string[] = data.whitelistedUsers || []; @@ -250,11 +271,19 @@ export default async function updateUserById( }); } - const response: Omit = { + const response = { ...userInfo, whitelistedUsers: newWhitelistedUsernames, image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "", - subscription: { active: subscriptions?.active }, + subscription: { + active: subscriptions?.active, + }, + parentSubscription: { + active: parentSubscription?.active, + user: { + email: parentSubscription?.user.email, + }, + }, }; return { response, status: 200 }; diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts index 0a89ea6..f81b61f 100644 --- a/lib/api/paymentCheckout.ts +++ b/lib/api/paymentCheckout.ts @@ -17,6 +17,7 @@ export default async function paymentCheckout( }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/lib/api/stripe/verifySubscription.ts b/lib/api/stripe/verifySubscription.ts index d4b756d..70f0ba6 100644 --- a/lib/api/stripe/verifySubscription.ts +++ b/lib/api/stripe/verifySubscription.ts @@ -4,17 +4,22 @@ import checkSubscriptionByEmail from "./checkSubscriptionByEmail"; interface UserIncludingSubscription extends User { subscriptions: Subscription | null; + parentSubscription: Subscription | null; } export default async function verifySubscription( user?: UserIncludingSubscription | null ) { - if (!user || !user.subscriptions) { + if (!user || (!user.subscriptions && !user.parentSubscription)) { return null; } + if (user.parentSubscription?.active) { + return user; + } + if ( - !user.subscriptions.active || + !user.subscriptions?.active || new Date() > user.subscriptions.currentPeriodEnd ) { const subscription = await checkSubscriptionByEmail(user.email as string); diff --git a/lib/api/verifyByCredentials.ts b/lib/api/verifyByCredentials.ts index 48baf5b..ccf406a 100644 --- a/lib/api/verifyByCredentials.ts +++ b/lib/api/verifyByCredentials.ts @@ -33,6 +33,7 @@ export default async function verifyByCredentials({ }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index 66017e4..cf4947a 100644 --- a/lib/api/verifyUser.ts +++ b/lib/api/verifyUser.ts @@ -30,6 +30,7 @@ export default async function verifyUser({ }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index cd9f585..cc40dae 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -1393,6 +1393,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index e37fe41..161338d 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -41,6 +41,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/pages/member-onboarding.tsx b/pages/member-onboarding.tsx new file mode 100644 index 0000000..6d69a7c --- /dev/null +++ b/pages/member-onboarding.tsx @@ -0,0 +1,158 @@ +import Button from "@/components/ui/Button"; +import TextInput from "@/components/TextInput"; +import CenteredForm from "@/layouts/CenteredForm"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { FormEvent, useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { Trans, useTranslation } from "next-i18next"; +import { useUpdateUser, useUser } from "@/hooks/store/user"; +import { useSession } from "next-auth/react"; + +interface FormData { + password: string; + name: string; +} + +export default function MemberOnboarding() { + const { t } = useTranslation(); + const [submitLoader, setSubmitLoader] = useState(false); + const router = useRouter(); + + const [form, setForm] = useState({ + password: "", + name: "", + }); + + const { data: user = {} } = useUser(); + const updateUser = useUpdateUser(); + + const { status } = useSession(); + + useEffect(() => { + toast.success(t("accepted_invitation_please_fill")); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + + if (form.password !== "" && form.name !== "" && !submitLoader) { + setSubmitLoader(true); + + const load = toast.loading(t("sending_password_recovery_link")); + + await updateUser.mutateAsync( + { + ...user, + name: form.name, + password: form.password, + }, + { + onSuccess: (data) => { + router.push("/dashboard"); + }, + onSettled: (data, error) => { + toast.dismiss(load); + + if (error) { + toast.error(error.message); + } else { + toast.success(t("settings_applied")); + } + }, + } + ); + + setSubmitLoader(false); + } else { + toast.error(t("please_fill_all_fields")); + } + } + + return ( + +
    +
    +

    + {t("finalize_profile")} +

    + +
    + +

    + {t("invitation_desc", { + owner: user?.parentSubscription?.user?.email, + })} +

    + +
    +

    + {t("display_name")} +

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

    + {t("new_password")} +

    + setForm({ ...form, password: e.target.value })} + /> +
    + + {process.env.NEXT_PUBLIC_STRIPE && ( +
    +

    + , + , + ]} + /> +

    +
    + )} + + +
    +
    +
    + ); +} + +export { getServerSideProps }; diff --git a/pages/subscribe.tsx b/pages/subscribe.tsx index f4f6844..20e9599 100644 --- a/pages/subscribe.tsx +++ b/pages/subscribe.tsx @@ -23,13 +23,13 @@ export default function Subscribe() { const { data: user = {} } = useUser(); useEffect(() => { + console.log("user", user); if ( session.status === "authenticated" && user.id && - user?.subscription?.active - ) { + (user?.subscription?.active || user.parentSubscription?.active) + ) router.push("/dashboard"); - } }, [session.status, user]); async function submit() { diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 93f772c..b493c75 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -403,5 +403,8 @@ "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" + "learn_more": "Learn more", + "finalize_profile": "Finalize Your Profile", + "invitation_desc": "{{owner}} invited you to join Linkwarden. \nTo continue, please finish setting up your account.", + "accepted_invitation_please_fill": "You've accepted the invitation to join Linkwarden. Please fill out the following to finalize your account." } \ No newline at end of file From cfd33e9bd1d01dab2bdd8edbd32e9e6b814d2fa9 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 26 Oct 2024 10:58:27 -0400 Subject: [PATCH 06/11] bug fixed --- components/ModalContent/BulkEditLinksModal.tsx | 3 +-- components/ModalContent/DeleteCollectionModal.tsx | 3 +-- components/ModalContent/DeleteUserModal.tsx | 5 +++-- components/ModalContent/EditCollectionModal.tsx | 3 +-- components/ModalContent/EditCollectionSharingModal.tsx | 3 +-- components/ModalContent/InviteModal.tsx | 3 +-- components/ModalContent/NewCollectionModal.tsx | 3 +-- components/ModalContent/NewLinkModal.tsx | 3 +-- components/ModalContent/NewTokenModal.tsx | 3 +-- components/ModalContent/NewUserModal.tsx | 5 +++-- components/ModalContent/UploadFileModal.tsx | 3 +-- pages/member-onboarding.tsx | 3 +-- pages/settings/account.tsx | 3 +-- pages/settings/password.tsx | 3 +-- pages/settings/preference.tsx | 3 +-- pages/tags/[id].tsx | 2 +- 16 files changed, 20 insertions(+), 31 deletions(-) diff --git a/components/ModalContent/BulkEditLinksModal.tsx b/components/ModalContent/BulkEditLinksModal.tsx index c2eab0f..3c78424 100644 --- a/components/ModalContent/BulkEditLinksModal.tsx +++ b/components/ModalContent/BulkEditLinksModal.tsx @@ -46,6 +46,7 @@ export default function BulkEditLinksModal({ onClose }: Props) { }, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -58,8 +59,6 @@ export default function BulkEditLinksModal({ onClose }: Props) { }, } ); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/DeleteCollectionModal.tsx b/components/ModalContent/DeleteCollectionModal.tsx index 0aae298..5e738b7 100644 --- a/components/ModalContent/DeleteCollectionModal.tsx +++ b/components/ModalContent/DeleteCollectionModal.tsx @@ -44,6 +44,7 @@ export default function DeleteCollectionModal({ deleteCollection.mutateAsync(collection.id as number, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -55,8 +56,6 @@ export default function DeleteCollectionModal({ } }, }); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx index 7323373..77931c6 100644 --- a/components/ModalContent/DeleteUserModal.tsx +++ b/components/ModalContent/DeleteUserModal.tsx @@ -23,9 +23,10 @@ export default function DeleteUserModal({ onClose, userId }: Props) { onSuccess: () => { onClose(); }, + onSettled: (data, error) => { + setSubmitLoader(false); + }, }); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/EditCollectionModal.tsx b/components/ModalContent/EditCollectionModal.tsx index 2c9ac8f..e76456a 100644 --- a/components/ModalContent/EditCollectionModal.tsx +++ b/components/ModalContent/EditCollectionModal.tsx @@ -35,6 +35,7 @@ export default function EditCollectionModal({ await updateCollection.mutateAsync(collection, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -45,8 +46,6 @@ export default function EditCollectionModal({ } }, }); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/EditCollectionSharingModal.tsx b/components/ModalContent/EditCollectionSharingModal.tsx index 921c3bb..16c61be 100644 --- a/components/ModalContent/EditCollectionSharingModal.tsx +++ b/components/ModalContent/EditCollectionSharingModal.tsx @@ -45,6 +45,7 @@ export default function EditCollectionSharingModal({ await updateCollection.mutateAsync(collection, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -55,8 +56,6 @@ export default function EditCollectionSharingModal({ } }, }); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/InviteModal.tsx b/components/ModalContent/InviteModal.tsx index 7f4ee09..1cb92d2 100644 --- a/components/ModalContent/InviteModal.tsx +++ b/components/ModalContent/InviteModal.tsx @@ -48,6 +48,7 @@ export default function InviteModal({ onClose }: Props) { await addUser.mutateAsync(form, { onSettled: () => { + setSubmitLoader(false); signIn("invite", { email: form.email, callbackUrl: "/member-onboarding", @@ -58,8 +59,6 @@ export default function InviteModal({ onClose }: Props) { onClose(); }, }); - - setSubmitLoader(false); } else { toast.error(t("fill_all_fields_error")); } diff --git a/components/ModalContent/NewCollectionModal.tsx b/components/ModalContent/NewCollectionModal.tsx index 191086a..b407c50 100644 --- a/components/ModalContent/NewCollectionModal.tsx +++ b/components/ModalContent/NewCollectionModal.tsx @@ -43,6 +43,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) { await createCollection.mutateAsync(collection, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -53,8 +54,6 @@ export default function NewCollectionModal({ onClose, parent }: Props) { } }, }); - - setSubmitLoader(false); }; return ( diff --git a/components/ModalContent/NewLinkModal.tsx b/components/ModalContent/NewLinkModal.tsx index e5394c9..7bb5444 100644 --- a/components/ModalContent/NewLinkModal.tsx +++ b/components/ModalContent/NewLinkModal.tsx @@ -80,6 +80,7 @@ export default function NewLinkModal({ onClose }: Props) { await addLink.mutateAsync(link, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -90,8 +91,6 @@ export default function NewLinkModal({ onClose }: Props) { } }, }); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/NewTokenModal.tsx b/components/ModalContent/NewTokenModal.tsx index 5c57012..7845fb7 100644 --- a/components/ModalContent/NewTokenModal.tsx +++ b/components/ModalContent/NewTokenModal.tsx @@ -34,6 +34,7 @@ export default function NewTokenModal({ onClose }: Props) { await addToken.mutateAsync(token, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -43,8 +44,6 @@ export default function NewTokenModal({ onClose }: Props) { } }, }); - - setSubmitLoader(false); } }; diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx index ac8b4dc..29aa6a9 100644 --- a/components/ModalContent/NewUserModal.tsx +++ b/components/ModalContent/NewUserModal.tsx @@ -55,9 +55,10 @@ export default function NewUserModal({ onClose }: Props) { onSuccess: () => { onClose(); }, + onSettled: () => { + setSubmitLoader(false); + }, }); - - setSubmitLoader(false); } else { toast.error(t("fill_all_fields_error")); } diff --git a/components/ModalContent/UploadFileModal.tsx b/components/ModalContent/UploadFileModal.tsx index 219dabe..20f4b26 100644 --- a/components/ModalContent/UploadFileModal.tsx +++ b/components/ModalContent/UploadFileModal.tsx @@ -115,6 +115,7 @@ export default function UploadFileModal({ onClose }: Props) { { link, file }, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -126,8 +127,6 @@ export default function UploadFileModal({ onClose }: Props) { }, } ); - - setSubmitLoader(false); } }; diff --git a/pages/member-onboarding.tsx b/pages/member-onboarding.tsx index 6d69a7c..7e76d10 100644 --- a/pages/member-onboarding.tsx +++ b/pages/member-onboarding.tsx @@ -53,6 +53,7 @@ export default function MemberOnboarding() { router.push("/dashboard"); }, onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -63,8 +64,6 @@ export default function MemberOnboarding() { }, } ); - - setSubmitLoader(false); } else { toast.error(t("please_fill_all_fields")); } diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 5a6e4f9..3a15b20 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -108,6 +108,7 @@ export default function Account() { } }, onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -129,8 +130,6 @@ export default function Account() { location.reload(); }, 1000); } - - setSubmitLoader(false); }; const importBookmarks = async ( diff --git a/pages/settings/password.tsx b/pages/settings/password.tsx index 808be7a..dfe5245 100644 --- a/pages/settings/password.tsx +++ b/pages/settings/password.tsx @@ -34,6 +34,7 @@ export default function Password() { }, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -47,8 +48,6 @@ export default function Password() { }, } ); - - setSubmitLoader(false); }; return ( diff --git a/pages/settings/preference.tsx b/pages/settings/preference.tsx index 4d93d02..13e0497 100644 --- a/pages/settings/preference.tsx +++ b/pages/settings/preference.tsx @@ -80,6 +80,7 @@ export default function Appearance() { { ...user }, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -90,8 +91,6 @@ export default function Appearance() { }, } ); - - setSubmitLoader(false); }; return ( diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index 14196a1..689517f 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -85,6 +85,7 @@ export default function Index() { }, { onSettled: (data, error) => { + setSubmitLoader(false); toast.dismiss(load); if (error) { @@ -97,7 +98,6 @@ export default function Index() { ); } - setSubmitLoader(false); setRenameTag(false); }; From b09de5a8af873706216bd9fd6a7e7e85004a8492 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 26 Oct 2024 13:44:52 -0400 Subject: [PATCH 07/11] updated verify max link logic --- .../controllers/collections/postCollection.ts | 15 ++-- lib/api/controllers/links/postLink.ts | 21 +++--- .../migration/importFromHTMLFile.ts | 50 +++++++------- .../migration/importFromLinkwarden.ts | 26 ++++--- .../migration/importFromWallabag.ts | 26 ++++--- lib/api/setLinkCollection.ts | 2 + lib/api/verifyCapacity.ts | 68 +++++++++++++++++++ .../migration.sql | 37 ++++++++++ prisma/schema.prisma | 8 +-- 9 files changed, 185 insertions(+), 68 deletions(-) create mode 100644 lib/api/verifyCapacity.ts create mode 100644 prisma/migrations/20241026161909_assign_createdby_to_collection_owners_and_make_field_required/migration.sql diff --git a/lib/api/controllers/collections/postCollection.ts b/lib/api/controllers/collections/postCollection.ts index c09291f..c6ae03e 100644 --- a/lib/api/controllers/collections/postCollection.ts +++ b/lib/api/controllers/collections/postCollection.ts @@ -44,11 +44,6 @@ export default async function postCollection( const newCollection = await prisma.collection.create({ data: { - owner: { - connect: { - id: userId, - }, - }, name: collection.name.trim(), description: collection.description, color: collection.color, @@ -61,6 +56,16 @@ export default async function postCollection( }, } : undefined, + owner: { + connect: { + id: userId, + }, + }, + createdBy: { + connect: { + id: userId, + }, + }, }, include: { _count: { diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index b620ee1..faf87ce 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -6,8 +6,7 @@ import { PostLinkSchema, PostLinkSchemaType, } from "@/lib/shared/schemaValidation"; - -const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; +import { hasPassedLimit } from "../../verifyCapacity"; export default async function postLink( body: PostLinkSchemaType, @@ -59,19 +58,14 @@ export default async function postLink( }; } - const numberOfLinksTheUserHas = await prisma.link.count({ - where: { - collection: { - ownerId: linkCollection.ownerId, - }, - }, - }); + const hasTooManyLinks = await hasPassedLimit(userId, 1); - if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER) + if (hasTooManyLinks) { return { - response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`, + response: `Your subscription have reached the maximum number of links allowed.`, status: 400, }; + } const { title, headers } = await fetchTitleAndHeaders(link.url || ""); @@ -98,6 +92,11 @@ export default async function postLink( name, description: link.description, type: linkType, + createdBy: { + connect: { + id: userId, + }, + }, collection: { connect: { id: linkCollection.id, diff --git a/lib/api/controllers/migration/importFromHTMLFile.ts b/lib/api/controllers/migration/importFromHTMLFile.ts index 2103806..b3b3913 100644 --- a/lib/api/controllers/migration/importFromHTMLFile.ts +++ b/lib/api/controllers/migration/importFromHTMLFile.ts @@ -2,8 +2,7 @@ import { prisma } from "@/lib/api/db"; import createFolder from "@/lib/api/storage/createFolder"; import { JSDOM } from "jsdom"; import { parse, Node, Element, TextNode } from "himalaya"; - -const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; +import { hasPassedLimit } from "../../verifyCapacity"; export default async function importFromHTMLFile( userId: number, @@ -20,19 +19,14 @@ export default async function importFromHTMLFile( const bookmarks = document.querySelectorAll("A"); const totalImports = bookmarks.length; - const numberOfLinksTheUserHas = await prisma.link.count({ - where: { - collection: { - ownerId: userId, - }, - }, - }); + const hasTooManyLinks = await hasPassedLimit(userId, totalImports); - if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER) + if (hasTooManyLinks) { return { - response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`, + response: `Your subscription have reached the maximum number of links allowed.`, status: 400, }; + } const jsonData = parse(document.documentElement.outerHTML); @@ -183,6 +177,11 @@ const createCollection = async ( id: userId, }, }, + createdBy: { + connect: { + id: userId, + }, + }, }, }); @@ -222,28 +221,27 @@ const createLink = async ( url, description, collectionId, + createdById: userId, tags: tags && tags[0] ? { connectOrCreate: tags.map((tag: string) => { - return ( - { - where: { - name_ownerId: { - name: tag.trim(), - ownerId: userId, - }, - }, - create: { + return { + where: { + name_ownerId: { name: tag.trim(), - owner: { - connect: { - id: userId, - }, + ownerId: userId, + }, + }, + create: { + name: tag.trim(), + owner: { + connect: { + id: userId, }, }, - } || undefined - ); + }, + }; }), } : undefined, diff --git a/lib/api/controllers/migration/importFromLinkwarden.ts b/lib/api/controllers/migration/importFromLinkwarden.ts index ed948d3..2ef1b52 100644 --- a/lib/api/controllers/migration/importFromLinkwarden.ts +++ b/lib/api/controllers/migration/importFromLinkwarden.ts @@ -1,8 +1,7 @@ import { prisma } from "@/lib/api/db"; import { Backup } from "@/types/global"; import createFolder from "@/lib/api/storage/createFolder"; - -const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; +import { hasPassedLimit } from "../../verifyCapacity"; export default async function importFromLinkwarden( userId: number, @@ -16,19 +15,14 @@ export default async function importFromLinkwarden( totalImports += collection.links.length; }); - const numberOfLinksTheUserHas = await prisma.link.count({ - where: { - collection: { - ownerId: userId, - }, - }, - }); + const hasTooManyLinks = await hasPassedLimit(userId, totalImports); - if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER) + if (hasTooManyLinks) { return { - response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`, + response: `Your subscription have reached the maximum number of links allowed.`, status: 400, }; + } await prisma .$transaction( @@ -47,6 +41,11 @@ export default async function importFromLinkwarden( name: e.name?.trim().slice(0, 254), description: e.description?.trim().slice(0, 254), color: e.color?.trim().slice(0, 50), + createdBy: { + connect: { + id: userId, + }, + }, }, }); @@ -72,6 +71,11 @@ export default async function importFromLinkwarden( id: newCollection.id, }, }, + createdBy: { + connect: { + id: userId, + }, + }, // Import Tags tags: { connectOrCreate: link.tags.map((tag) => ({ diff --git a/lib/api/controllers/migration/importFromWallabag.ts b/lib/api/controllers/migration/importFromWallabag.ts index a1f6a0b..c82df85 100644 --- a/lib/api/controllers/migration/importFromWallabag.ts +++ b/lib/api/controllers/migration/importFromWallabag.ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/api/db"; import createFolder from "@/lib/api/storage/createFolder"; - -const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; +import { hasPassedLimit } from "../../verifyCapacity"; type WallabagBackup = { is_archived: number; @@ -36,19 +35,14 @@ export default async function importFromWallabag( let totalImports = backup.length; - const numberOfLinksTheUserHas = await prisma.link.count({ - where: { - collection: { - ownerId: userId, - }, - }, - }); + const hasTooManyLinks = await hasPassedLimit(userId, totalImports); - if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER) + if (hasTooManyLinks) { return { - response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`, + response: `Your subscription have reached the maximum number of links allowed.`, status: 400, }; + } await prisma .$transaction( @@ -61,6 +55,11 @@ export default async function importFromWallabag( }, }, name: "Imports", + createdBy: { + connect: { + id: userId, + }, + }, }, }); @@ -89,6 +88,11 @@ export default async function importFromWallabag( id: newCollection.id, }, }, + createdBy: { + connect: { + id: userId, + }, + }, tags: link.tags && link.tags[0] ? { diff --git a/lib/api/setLinkCollection.ts b/lib/api/setLinkCollection.ts index c26de99..f5270a7 100644 --- a/lib/api/setLinkCollection.ts +++ b/lib/api/setLinkCollection.ts @@ -45,6 +45,7 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => { data: { name: link.collection.name.trim(), ownerId: userId, + createdById: userId, }, }); @@ -78,6 +79,7 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => { name: "Unorganized", ownerId: userId, parentId: null, + createdById: userId, }, }); } diff --git a/lib/api/verifyCapacity.ts b/lib/api/verifyCapacity.ts new file mode 100644 index 0000000..bf41b42 --- /dev/null +++ b/lib/api/verifyCapacity.ts @@ -0,0 +1,68 @@ +import { prisma } from "./db"; + +const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; +const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; + +export const hasPassedLimit = async ( + userId: number, + numberOfImports: number +) => { + if (!stripeEnabled) { + return false; + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + parentSubscription: true, + subscriptions: true, + }, + }); + + if (!user) { + return true; + } + + if ( + user.parentSubscription || + (user.subscriptions && user.subscriptions?.quantity > 1) + ) { + const subscription = user.parentSubscription || user.subscriptions; + + if (!subscription) { + return true; + } + + // Calculate the total allowed links for the organization + const totalCapacity = subscription.quantity * MAX_LINKS_PER_USER; + + const totalLinks = await prisma.link.count({ + where: { + createdBy: { + OR: [ + { + parentSubscriptionId: subscription.id || undefined, + }, + { + subscriptions: { + id: subscription.id || undefined, + }, + }, + ], + }, + }, + }); + + return totalCapacity - (numberOfImports + totalLinks) < 0; + } else { + const totalLinks = await prisma.link.count({ + where: { + createdBy: { + id: userId, + }, + }, + }); + + return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0; + } +}; diff --git a/prisma/migrations/20241026161909_assign_createdby_to_collection_owners_and_make_field_required/migration.sql b/prisma/migrations/20241026161909_assign_createdby_to_collection_owners_and_make_field_required/migration.sql new file mode 100644 index 0000000..08748b2 --- /dev/null +++ b/prisma/migrations/20241026161909_assign_createdby_to_collection_owners_and_make_field_required/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - Made the column `createdById` on table `Collection` required. This step will fail if there are existing NULL values in that column. + - Made the column `createdById` on table `Link` required. This step will fail if there are existing NULL values in that column. + +*/ + +-- Update the Link table to set the createdBy based on the Collection's ownerId. +UPDATE "Link" +SET "createdById" = ( + SELECT "ownerId" + FROM "Collection" + WHERE "Collection"."id" = "Link"."collectionId" +); + +-- Set createdBy to ownerId for existing records +UPDATE "Collection" +SET "createdById" = "ownerId"; + +-- DropForeignKey +ALTER TABLE "Collection" DROP CONSTRAINT "Collection_createdById_fkey"; + +-- DropForeignKey +ALTER TABLE "Link" DROP CONSTRAINT "Link_createdById_fkey"; + +-- AlterTable +ALTER TABLE "Collection" ALTER COLUMN "createdById" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Link" ALTER COLUMN "createdById" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Collection" ADD CONSTRAINT "Collection_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Link" ADD CONSTRAINT "Link_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce4ca12..d06b717 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,8 +115,8 @@ model Collection { ownerId Int members UsersAndCollections[] teamId Int? - createdBy User? @relation("CreatedCollections", fields: [createdById], references: [id]) - createdById Int? + createdBy User @relation("CreatedCollections", fields: [createdById], references: [id]) + createdById Int links Link[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -145,8 +145,8 @@ model Link { type String @default("url") description String @default("") pinnedBy User[] @relation("PinnedLinks") - createdBy User? @relation("CreatedLinks", fields: [createdById], references: [id]) - createdById Int? + createdBy User @relation("CreatedLinks", fields: [createdById], references: [id]) + createdById Int collection Collection @relation(fields: [collectionId], references: [id]) collectionId Int tags Tag[] From 665019dc59c680a9928e9fd52962fa703ab2e78a Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 29 Oct 2024 18:08:47 -0400 Subject: [PATCH 08/11] finalizing team support --- components/ModalContent/DeleteUserModal.tsx | 25 +- .../EditCollectionSharingModal.tsx | 6 +- components/ModalContent/InviteModal.tsx | 6 +- components/ProfileDropdown.tsx | 7 +- components/ui/Divider.tsx | 12 + layouts/AuthRedirect.tsx | 1 - .../collectionId/updateCollectionById.ts | 8 +- .../controllers/public/users/getPublicUser.ts | 16 +- lib/api/controllers/users/getUsers.ts | 80 +++++-- .../users/userId/deleteUserById.ts | 101 ++++++-- .../controllers/users/userId/getUserById.ts | 1 + .../users/userId/updateUserById.ts | 1 + lib/client/addMemberToCollection.ts | 19 +- pages/admin.tsx | 3 +- pages/api/v1/users/[id].ts | 24 +- pages/api/v1/users/index.ts | 5 +- pages/member-onboarding.tsx | 5 +- pages/settings/access-tokens.tsx | 12 +- pages/settings/billing.tsx | 225 +++++++++++++++++- pages/team.tsx | 108 --------- .../20241027093300_remove_field/migration.sql | 11 + .../20241027104510_remove_field/migration.sql | 8 + prisma/schema.prisma | 7 - public/locales/en/common.json | 19 +- types/global.ts | 2 +- 25 files changed, 511 insertions(+), 201 deletions(-) create mode 100644 components/ui/Divider.tsx delete mode 100644 pages/team.tsx create mode 100644 prisma/migrations/20241027093300_remove_field/migration.sql create mode 100644 prisma/migrations/20241027104510_remove_field/migration.sql diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx index 77931c6..c7d5a4a 100644 --- a/components/ModalContent/DeleteUserModal.tsx +++ b/components/ModalContent/DeleteUserModal.tsx @@ -3,6 +3,7 @@ import Button from "../ui/Button"; import { useTranslation } from "next-i18next"; import { useDeleteUser } from "@/hooks/store/admin/users"; import { useState } from "react"; +import { useSession } from "next-auth/react"; type Props = { onClose: Function; @@ -30,25 +31,33 @@ export default function DeleteUserModal({ onClose, userId }: Props) { } }; + const { data } = useSession(); + const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN); + return ( -

    {t("delete_user")}

    +

    + {isAdmin ? t("delete_user") : t("remove_user")} +

    {t("confirm_user_deletion")}

    +

    {t("confirm_user_removal_desc")}

    -
    - - - {t("warning")}: {t("irreversible_action_warning")} - -
    + {isAdmin && ( +
    + + + {t("warning")}: {t("irreversible_action_warning")} + +
    + )}
    diff --git a/components/ModalContent/EditCollectionSharingModal.tsx b/components/ModalContent/EditCollectionSharingModal.tsx index 16c61be..f44336f 100644 --- a/components/ModalContent/EditCollectionSharingModal.tsx +++ b/components/ModalContent/EditCollectionSharingModal.tsx @@ -150,12 +150,12 @@ export default function EditCollectionSharingModal({ setMemberUsername(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addMemberToCollection( - user.username as string, + user, memberUsername || "", collection, setMemberState, @@ -167,7 +167,7 @@ export default function EditCollectionSharingModal({
    addMemberToCollection( - user.username as string, + user, memberUsername || "", collection, setMemberState, diff --git a/components/ModalContent/InviteModal.tsx b/components/ModalContent/InviteModal.tsx index 1cb92d2..6d83e83 100644 --- a/components/ModalContent/InviteModal.tsx +++ b/components/ModalContent/InviteModal.tsx @@ -49,13 +49,13 @@ export default function InviteModal({ onClose }: Props) { await addUser.mutateAsync(form, { onSettled: () => { setSubmitLoader(false); - signIn("invite", { + }, + onSuccess: async () => { + await signIn("invite", { email: form.email, callbackUrl: "/member-onboarding", redirect: false, }); - }, - onSuccess: () => { onClose(); }, }); diff --git a/components/ProfileDropdown.tsx b/components/ProfileDropdown.tsx index acab61e..81e0505 100644 --- a/components/ProfileDropdown.tsx +++ b/components/ProfileDropdown.tsx @@ -12,7 +12,6 @@ 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"; @@ -74,16 +73,16 @@ export default function ProfileDropdown() {
  • )} - {!DISABLE_INVITES && ( + {!user.parentSubscriptionId && (
  • (document?.activeElement as HTMLElement)?.blur()} tabIndex={0} role="button" className="whitespace-nowrap" > - {t("manage_team")} + {t("invite_users")}
  • )} diff --git a/components/ui/Divider.tsx b/components/ui/Divider.tsx new file mode 100644 index 0000000..80f50d8 --- /dev/null +++ b/components/ui/Divider.tsx @@ -0,0 +1,12 @@ +import clsx from "clsx"; +import React from "react"; + +type Props = { + className?: string; +}; + +function Divider({ className }: Props) { + return
    ; +} + +export default Divider; diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index ad1d0be..969427e 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -44,7 +44,6 @@ 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/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts index 0319a65..2f78d1c 100644 --- a/lib/api/controllers/collections/collectionId/updateCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/updateCollectionById.ts @@ -58,6 +58,12 @@ export default async function updateCollection( } } + const uniqueMembers = data.members.filter( + (e, i, a) => + a.findIndex((el) => el.userId === e.userId) === i && + e.userId !== collectionIsAccessible.ownerId + ); + const updatedCollection = await prisma.$transaction(async () => { await prisma.usersAndCollections.deleteMany({ where: { @@ -91,7 +97,7 @@ export default async function updateCollection( } : undefined, members: { - create: data.members.map((e) => ({ + create: uniqueMembers.map((e) => ({ user: { connect: { id: e.userId } }, canCreate: e.canCreate, canUpdate: e.canUpdate, diff --git a/lib/api/controllers/public/users/getPublicUser.ts b/lib/api/controllers/public/users/getPublicUser.ts index 68b6fab..66bf613 100644 --- a/lib/api/controllers/public/users/getPublicUser.ts +++ b/lib/api/controllers/public/users/getPublicUser.ts @@ -5,13 +5,20 @@ export default async function getPublicUser( isId: boolean, requestingId?: number ) { - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: isId ? { id: Number(targetId) as number, } : { - username: targetId as string, + OR: [ + { + username: targetId as string, + }, + { + email: targetId as string, + }, + ], }, include: { whitelistedUsers: { @@ -22,7 +29,7 @@ export default async function getPublicUser( }, }); - if (!user) + if (!user || !user.id) return { response: "User not found or profile is private.", status: 404 }; const whitelistedUsernames = user.whitelistedUsers?.map( @@ -31,7 +38,7 @@ export default async function getPublicUser( const isInAPublicCollection = await prisma.collection.findFirst({ where: { - ["OR"]: [ + OR: [ { ownerId: user.id }, { members: { @@ -73,6 +80,7 @@ export default async function getPublicUser( id: lessSensitiveInfo.id, name: lessSensitiveInfo.name, username: lessSensitiveInfo.username, + email: lessSensitiveInfo.email, image: lessSensitiveInfo.image, archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot, archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith, diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts index dd589c5..2175fd9 100644 --- a/lib/api/controllers/users/getUsers.ts +++ b/lib/api/controllers/users/getUsers.ts @@ -1,21 +1,71 @@ import { prisma } from "@/lib/api/db"; +import { User } from "@prisma/client"; -export default async function getUsers() { - // Get all users - const users = await prisma.user.findMany({ - select: { - id: true, - username: true, - email: true, - emailVerified: true, - subscriptions: { - select: { - active: true, +export default async function getUsers(user: User) { + if (user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) { + const users = await prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + emailVerified: true, + subscriptions: { + select: { + active: true, + }, }, + createdAt: true, }, - createdAt: true, - }, - }); + }); - return { response: users.sort((a: any, b: any) => a.id - b.id), status: 200 }; + return { + response: users.sort((a: any, b: any) => a.id - b.id), + status: 200, + }; + } else { + let subscriptionId = ( + await prisma.subscription.findFirst({ + where: { + userId: user.id, + }, + select: { + id: true, + }, + }) + )?.id; + + if (!subscriptionId) + return { + response: "Subscription not found.", + status: 404, + }; + + const users = await prisma.user.findMany({ + where: { + OR: [ + { + parentSubscriptionId: subscriptionId, + }, + { + subscriptions: { + id: subscriptionId, + }, + }, + ], + }, + select: { + id: true, + name: true, + username: true, + email: true, + emailVerified: true, + createdAt: true, + }, + }); + + return { + response: users.sort((a: any, b: any) => a.id - b.id), + status: 200, + }; + } } diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index a481893..2c3d864 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -4,15 +4,19 @@ import removeFolder from "@/lib/api/storage/removeFolder"; import Stripe from "stripe"; import { DeleteUserBody } from "@/types/global"; import removeFile from "@/lib/api/storage/removeFile"; +import updateSeats from "@/lib/api/stripe/updateSeats"; export default async function deleteUserById( userId: number, body: DeleteUserBody, - isServerAdmin?: boolean + isServerAdmin: boolean, + queryId: number ) { - // First, we retrieve the user from the database const user = await prisma.user.findUnique({ where: { id: userId }, + include: { + subscriptions: true, + }, }); if (!user) { @@ -23,21 +27,74 @@ export default async function deleteUserById( } if (!isServerAdmin) { - if (user.password) { - const isPasswordValid = bcrypt.compareSync(body.password, user.password); + if (queryId === userId) { + if (user.password) { + const isPasswordValid = bcrypt.compareSync( + body.password, + user.password + ); - if (!isPasswordValid && !isServerAdmin) { + if (!isPasswordValid && !isServerAdmin) { + return { + response: "Invalid credentials.", + status: 401, + }; + } + } else { return { - response: "Invalid credentials.", - status: 401, // Unauthorized + response: + "User has no password. Please reset your password from the forgot password page.", + status: 401, }; } } else { - return { - response: - "User has no password. Please reset your password from the forgot password page.", - status: 401, // Unauthorized - }; + if (user.parentSubscriptionId) { + console.log(userId, user.parentSubscriptionId); + + return { + response: "Permission denied.", + status: 401, + }; + } else { + if (!user.subscriptions) { + return { + response: "User has no subscription.", + status: 401, + }; + } + + const findChild = await prisma.user.findFirst({ + where: { id: queryId, parentSubscriptionId: user.subscriptions?.id }, + }); + + if (!findChild) + return { + response: "Permission denied.", + status: 401, + }; + + const removeUser = await prisma.user.update({ + where: { id: findChild.id }, + data: { + parentSubscription: { + disconnect: true, + }, + }, + select: { + id: true, + }, + }); + + await updateSeats( + user.subscriptions.stripeSubscriptionId, + user.subscriptions.quantity - 1 + ); + + return { + response: removeUser, + status: 200, + }; + } } } @@ -47,27 +104,27 @@ export default async function deleteUserById( async (prisma) => { // Delete Access Tokens await prisma.accessToken.deleteMany({ - where: { userId }, + where: { userId: queryId }, }); // Delete whitelisted users await prisma.whitelistedUser.deleteMany({ - where: { userId }, + where: { userId: queryId }, }); // Delete links await prisma.link.deleteMany({ - where: { collection: { ownerId: userId } }, + where: { collection: { ownerId: queryId } }, }); // Delete tags await prisma.tag.deleteMany({ - where: { ownerId: userId }, + where: { ownerId: queryId }, }); // Find collections that the user owns const collections = await prisma.collection.findMany({ - where: { ownerId: userId }, + where: { ownerId: queryId }, }); for (const collection of collections) { @@ -86,29 +143,29 @@ export default async function deleteUserById( // Delete collections after cleaning up related data await prisma.collection.deleteMany({ - where: { ownerId: userId }, + where: { ownerId: queryId }, }); // Delete subscription if (process.env.STRIPE_SECRET_KEY) await prisma.subscription .delete({ - where: { userId }, + where: { userId: queryId }, }) .catch((err) => console.log(err)); await prisma.usersAndCollections.deleteMany({ where: { - OR: [{ userId: userId }, { collection: { ownerId: userId } }], + OR: [{ userId: queryId }, { collection: { ownerId: queryId } }], }, }); // Delete user's avatar - await removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); + await removeFile({ filePath: `uploads/avatar/${queryId}.jpg` }); // Finally, delete the user await prisma.user.delete({ - where: { id: userId }, + where: { id: queryId }, }); }, { timeout: 20000 } diff --git a/lib/api/controllers/users/userId/getUserById.ts b/lib/api/controllers/users/userId/getUserById.ts index f704155..062a70a 100644 --- a/lib/api/controllers/users/userId/getUserById.ts +++ b/lib/api/controllers/users/userId/getUserById.ts @@ -35,6 +35,7 @@ export default async function getUserById(userId: number) { whitelistedUsers: whitelistedUsernames, subscription: { active: subscriptions?.active, + quantity: subscriptions?.quantity, }, parentSubscription: { active: parentSubscription?.active, diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 49e5c4c..6dd09a8 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -277,6 +277,7 @@ export default async function updateUserById( image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "", subscription: { active: subscriptions?.active, + quantity: subscriptions?.quantity, }, parentSubscription: { active: parentSubscription?.active, diff --git a/lib/client/addMemberToCollection.ts b/lib/client/addMemberToCollection.ts index 4e03720..7fa138f 100644 --- a/lib/client/addMemberToCollection.ts +++ b/lib/client/addMemberToCollection.ts @@ -2,9 +2,10 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import getPublicUserData from "./getPublicUserData"; import { toast } from "react-hot-toast"; import { TFunction } from "i18next"; +import { User } from "@prisma/client"; const addMemberToCollection = async ( - ownerUsername: string, + owner: User, memberUsername: string, collection: CollectionIncludingMembersAndLinkCount, setMember: (newMember: Member) => null | undefined, @@ -12,7 +13,12 @@ const addMemberToCollection = async ( ) => { const checkIfMemberAlreadyExists = collection.members.find((e) => { const username = (e.user.username || "").toLowerCase(); - return username === memberUsername.toLowerCase(); + const email = (e.user.email || "").toLowerCase(); + + return ( + username === memberUsername.toLowerCase() || + email === memberUsername.toLowerCase() + ); }); if ( @@ -21,7 +27,8 @@ const addMemberToCollection = async ( // member can't be empty memberUsername.trim() !== "" && // member can't be the owner - memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase() + memberUsername.trim().toLowerCase() !== owner.username?.toLowerCase() && + memberUsername.trim().toLowerCase() !== owner.email?.toLowerCase() ) { // Lookup, get data/err, list ... const user = await getPublicUserData(memberUsername.trim().toLowerCase()); @@ -37,12 +44,16 @@ const addMemberToCollection = async ( id: user.id, name: user.name, username: user.username, + email: user.email, image: user.image, }, }); } } else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member")); - else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase()) + else if ( + memberUsername.trim().toLowerCase() === owner.username?.toLowerCase() || + memberUsername.trim().toLowerCase() === owner.email?.toLowerCase() + ) toast.error(t("you_are_already_collection_owner")); }; diff --git a/pages/admin.tsx b/pages/admin.tsx index 31978b6..280e674 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; import UserListing from "@/components/UserListing"; import { useUsers } from "@/hooks/store/admin/users"; +import Divider from "@/components/ui/Divider"; interface User extends U { subscriptions: { @@ -88,7 +89,7 @@ export default function Admin() { -
    + {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? ( UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t) diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index 161338d..9cf4fc9 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -11,6 +11,12 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; export default async function users(req: NextApiRequest, res: NextApiResponse) { const token = await verifyToken({ req }); + const queryId = Number(req.query.id); + + if (!queryId) { + return res.status(400).json({ response: "Invalid request." }); + } + if (typeof token === "string") { res.status(401).json({ response: token }); return null; @@ -24,12 +30,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { const isServerAdmin = user?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1); - const userId = isServerAdmin ? Number(req.query.id) : token.id; - - if (userId !== Number(req.query.id) && !isServerAdmin) - return res.status(401).json({ response: "Permission denied." }); + const userId = token.id; if (req.method === "GET") { + if (userId !== queryId && !isServerAdmin) + return res.status(401).json({ response: "Permission denied." }); + const users = await getUserById(userId); return res.status(users.status).json({ response: users.response }); } @@ -59,6 +65,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { } if (req.method === "PUT") { + if (userId !== queryId && !isServerAdmin) + return res.status(401).json({ response: "Permission denied." }); + if (process.env.NEXT_PUBLIC_DEMO === "true") return res.status(400).json({ response: @@ -74,7 +83,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { "This action is disabled because this is a read-only demo of Linkwarden.", }); - const updated = await deleteUserById(userId, req.body, isServerAdmin); + const updated = await deleteUserById( + userId, + req.body, + isServerAdmin, + queryId + ); return res.status(updated.status).json({ response: updated.response }); } } diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts index 356a9f0..feebb5a 100644 --- a/pages/api/v1/users/index.ts +++ b/pages/api/v1/users/index.ts @@ -16,10 +16,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { } else if (req.method === "GET") { const user = await verifyUser({ req, res }); - if (!user || user.id !== Number(process.env.NEXT_PUBLIC_ADMIN || 1)) - return res.status(401).json({ response: "Unauthorized..." }); + if (!user) return res.status(401).json({ response: "Unauthorized..." }); - const response = await getUsers(); + const response = await getUsers(user); return res.status(response.status).json({ response: response.response }); } } diff --git a/pages/member-onboarding.tsx b/pages/member-onboarding.tsx index 7e76d10..57aad68 100644 --- a/pages/member-onboarding.tsx +++ b/pages/member-onboarding.tsx @@ -8,7 +8,6 @@ import { toast } from "react-hot-toast"; import getServerSideProps from "@/lib/client/getServerSideProps"; import { Trans, useTranslation } from "next-i18next"; import { useUpdateUser, useUser } from "@/hooks/store/user"; -import { useSession } from "next-auth/react"; interface FormData { password: string; @@ -28,8 +27,6 @@ export default function MemberOnboarding() { const { data: user = {} } = useUser(); const updateUser = useUpdateUser(); - const { status } = useSession(); - useEffect(() => { toast.success(t("accepted_invitation_please_fill")); }, []); @@ -146,7 +143,7 @@ export default function MemberOnboarding() { size="full" loading={submitLoader} > - {t("sign_up")} + {t("continue_to_dashboard")} diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx index f75789d..963b512 100644 --- a/pages/settings/access-tokens.tsx +++ b/pages/settings/access-tokens.tsx @@ -67,10 +67,18 @@ export default function AccessTokens() { )} - {new Date(token.createdAt || "").toLocaleDateString()} + {new Date(token.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} - {new Date(token.expires || "").toLocaleDateString()} + {new Date(token.expires).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })}