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/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..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; @@ -23,31 +24,40 @@ export default function DeleteUserModal({ onClose, userId }: Props) { onSuccess: () => { onClose(); }, + onSettled: (data, error) => { + setSubmitLoader(false); + }, }); - - setSubmitLoader(false); } }; + 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/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 5f50018..d78abc2 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); } }; @@ -67,7 +66,7 @@ export default function EditCollectionSharingModal({ const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`; - const [memberUsername, setMemberUsername] = useState(""); + const [memberIdentifier, setMemberIdentifier] = useState(""); const [collectionOwner, setCollectionOwner] = useState< Partial @@ -92,7 +91,7 @@ export default function EditCollectionSharingModal({ members: [...collection.members, newMember], }); - setMemberUsername(""); + setMemberIdentifier(""); }; return ( @@ -174,15 +173,15 @@ export default function EditCollectionSharingModal({
setMemberUsername(e.target.value)} + placeholder={t("add_member_placeholder")} + onChange={(e) => setMemberIdentifier(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addMemberToCollection( - user.username as string, - memberUsername || "", + user, + memberIdentifier.replace(/^@/, "") || "", collection, setMemberState, t @@ -193,8 +192,8 @@ export default function EditCollectionSharingModal({
addMemberToCollection( - user.username as string, - memberUsername || "", + user, + memberIdentifier.replace(/^@/, "") || "", collection, setMemberState, t diff --git a/components/ModalContent/InviteModal.tsx b/components/ModalContent/InviteModal.tsx new file mode 100644 index 0000000..6d83e83 --- /dev/null +++ b/components/ModalContent/InviteModal.tsx @@ -0,0 +1,125 @@ +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: () => { + setSubmitLoader(false); + }, + onSuccess: async () => { + await signIn("invite", { + email: form.email, + callbackUrl: "/member-onboarding", + redirect: false, + }); + onClose(); + }, + }); + } 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/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 1b4fb6d..29aa6a9 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 !== ""; @@ -52,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/components/ProfileDropdown.tsx b/components/ProfileDropdown.tsx index 80d5119..9d1260b 100644 --- a/components/ProfileDropdown.tsx +++ b/components/ProfileDropdown.tsx @@ -6,6 +6,8 @@ import { signOut } from "next-auth/react"; import { useTranslation } from "next-i18next"; import { useUser } from "@/hooks/store/user"; +const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; + export default function ProfileDropdown() { const { t } = useTranslation(); const { settings, updateSettings } = useLocalSettingsStore(); @@ -73,6 +75,19 @@ export default function ProfileDropdown() { )} + {!user.parentSubscriptionId && stripeEnabled && ( +
  • + (document?.activeElement as HTMLElement)?.blur()} + tabIndex={0} + role="button" + className="whitespace-nowrap" + > + {t("invite_users")} + +
  • + )}
  • { 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/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index 54dfb53..bfc1ed7 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -2,11 +2,14 @@ import Link from "next/link"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import { useTranslation } from "next-i18next"; +import { useUser } from "@/hooks/store/user"; export default function SettingsSidebar({ className }: { className?: string }) { const { t } = useTranslation(); const LINKWARDEN_VERSION = process.env.version; + const { data: user } = useUser(); + const router = useRouter(); const [active, setActive] = useState(""); @@ -73,7 +76,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
    - {process.env.NEXT_PUBLIC_STRIPE && ( + {process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
    ; +} + +export default Divider; 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..969427e 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 = [ @@ -49,6 +52,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/checkSubscriptionByEmail.ts b/lib/api/checkSubscriptionByEmail.ts deleted file mode 100644 index 62a2320..0000000 --- a/lib/api/checkSubscriptionByEmail.ts +++ /dev/null @@ -1,53 +0,0 @@ -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, - }; - - const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: "2022-11-15", - }); - - console.log("Request made to Stripe by:", email); - const listByEmail = await stripe.customers.list({ - email: email.toLowerCase(), - 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, - }; -} diff --git a/lib/api/controllers/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts index 2aed55f..c942eb0 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, @@ -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: { @@ -80,19 +86,19 @@ export default async function updateCollection( isPublic: data.isPublic, tagsArePublic: data.tagsArePublic, 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, } : 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/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/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 496efcf..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, 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/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/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index a481893..2570b25 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -4,15 +4,28 @@ 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: { + include: { + user: true, + }, + }, + parentSubscription: { + include: { + user: true, + }, + }, + }, }); if (!user) { @@ -23,21 +36,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 +113,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 +152,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 } @@ -121,24 +187,36 @@ export default async function deleteUserById( }); try { - const listByEmail = await stripe.customers.list({ - email: user.email?.toLowerCase(), - expand: ["data.subscriptions"], - }); + if (user.subscriptions?.id) { + const listByEmail = await stripe.customers.list({ + email: user.email?.toLowerCase(), + expand: ["data.subscriptions"], + }); - if (listByEmail.data[0].subscriptions?.data[0].id) { - const deleted = await stripe.subscriptions.cancel( - listByEmail.data[0].subscriptions?.data[0].id, - { - cancellation_details: { - comment: body.cancellation_details?.comment, - feedback: body.cancellation_details?.feedback, - }, - } + if (listByEmail.data[0].subscriptions?.data[0].id) { + const deleted = await stripe.subscriptions.cancel( + listByEmail.data[0].subscriptions?.data[0].id, + { + cancellation_details: { + comment: body.cancellation_details?.comment, + feedback: body.cancellation_details?.feedback, + }, + } + ); + + return { + response: deleted, + status: 200, + }; + } + } else if (user.parentSubscription?.id) { + await updateSeats( + user.parentSubscription.stripeSubscriptionId, + user.parentSubscription.quantity - 1 ); return { - response: deleted, + response: "User account and all related data deleted successfully.", status: 200, }; } diff --git a/lib/api/controllers/users/userId/getUserById.ts b/lib/api/controllers/users/userId/getUserById.ts index 8c6faa8..062a70a 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,13 +27,21 @@ export default async function getUserById(userId: number) { (usernames) => usernames.username ); - const { password, subscriptions, ...lessSensitiveInfo } = user; + const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } = + user; const data = { ...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames, subscription: { active: subscriptions?.active, + quantity: subscriptions?.quantity, + }, + parentSubscription: { + active: parentSubscription?.active, + user: { + email: parentSubscription?.user.email, + }, }, }; diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index c62a594..6dd09a8 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) { @@ -133,7 +132,7 @@ export default async function updateUserById( sendChangeEmailVerificationRequest( user.email, data.email, - data.name?.trim() || user.name + data.name?.trim() || user.name || "Linkwarden User" ); } @@ -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,20 @@ 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, + quantity: subscriptions?.quantity, + }, + parentSubscription: { + active: parentSubscription?.active, + user: { + email: parentSubscription?.user.email, + }, + }, }; return { response, status: 200 }; 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 bae7f1d..f81b61f 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,23 @@ export default async function paymentCheckout( apiVersion: "2022-11-15", }); + const user = await prisma.user.findUnique({ + where: { + email: email.toLowerCase(), + }, + include: { + subscriptions: true, + parentSubscription: 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"], @@ -18,6 +37,7 @@ 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: [ @@ -28,7 +48,7 @@ export default async function paymentCheckout( ], 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/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/stripe/checkSubscriptionByEmail.ts b/lib/api/stripe/checkSubscriptionByEmail.ts new file mode 100644 index 0000000..1df51a7 --- /dev/null +++ b/lib/api/stripe/checkSubscriptionByEmail.ts @@ -0,0 +1,31 @@ +import Stripe from "stripe"; + +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + +export default async function checkSubscriptionByEmail(email: string) { + if (!STRIPE_SECRET_KEY) return null; + + const stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + + console.log("Request made to Stripe by:", email); + const listByEmail = await stripe.customers.list({ + email: email.toLowerCase(), + expand: ["data.subscriptions"], + }); + + 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/stripe/handleSubscription.ts b/lib/api/stripe/handleSubscription.ts new file mode 100644 index 0000000..8f41e2e --- /dev/null +++ b/lib/api/stripe/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/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/stripe/verifySubscription.ts b/lib/api/stripe/verifySubscription.ts new file mode 100644 index 0000000..70f0ba6 --- /dev/null +++ b/lib/api/stripe/verifySubscription.ts @@ -0,0 +1,70 @@ +import { prisma } from "../db"; +import { Subscription, User } from "@prisma/client"; +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 && !user.parentSubscription)) { + return null; + } + + if (user.parentSubscription?.active) { + return user; + } + + if ( + !user.subscriptions?.active || + new Date() > user.subscriptions.currentPeriodEnd + ) { + const subscription = await checkSubscriptionByEmail(user.email as string); + + if ( + !subscription || + !subscription.stripeSubscriptionId || + !subscription.currentPeriodEnd || + !subscription.currentPeriodStart || + !subscription.quantity + ) { + return null; + } + + const { + active, + stripeSubscriptionId, + currentPeriodStart, + currentPeriodEnd, + quantity, + } = subscription; + + await prisma.subscription + .upsert({ + where: { + userId: user.id, + }, + 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/lib/api/verifyByCredentials.ts b/lib/api/verifyByCredentials.ts index a0bbba2..ccf406a 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 = { @@ -33,6 +33,7 @@ export default async function verifyByCredentials({ }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/lib/api/verifyCapacity.ts b/lib/api/verifyCapacity.ts new file mode 100644 index 0000000..c87984b --- /dev/null +++ b/lib/api/verifyCapacity.ts @@ -0,0 +1,76 @@ +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) { + const totalLinks = await prisma.link.count({ + where: { + createdBy: { + id: userId, + }, + }, + }); + + return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0; + } + + 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/lib/api/verifySubscription.ts b/lib/api/verifySubscription.ts deleted file mode 100644 index b153cc6..0000000 --- a/lib/api/verifySubscription.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { prisma } from "./db"; -import { Subscription, User } from "@prisma/client"; -import checkSubscriptionByEmail from "./checkSubscriptionByEmail"; - -interface UserIncludingSubscription extends User { - subscriptions: Subscription | null; -} - -export default async function verifySubscription( - user?: UserIncludingSubscription -) { - if (!user) { - return null; - } - - const subscription = user.subscriptions; - - const currentDate = new Date(); - - if (!subscription?.active || currentDate > subscription.currentPeriodEnd) { - const { - active, - stripeSubscriptionId, - currentPeriodStart, - currentPeriodEnd, - } = await checkSubscriptionByEmail(user.email as string); - - 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({ - where: { - userId: user.id, - }, - }); - - if (subscription) - await prisma.subscription.delete({ - where: { - userId: user.id, - }, - }); - - return null; - } - } - - return user; -} diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index d77a74f..cf4947a 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 = { @@ -30,6 +30,7 @@ export default async function verifyUser({ }, include: { subscriptions: true, + parentSubscription: true, }, }); diff --git a/lib/client/addMemberToCollection.ts b/lib/client/addMemberToCollection.ts index 4e03720..4b23399 100644 --- a/lib/client/addMemberToCollection.ts +++ b/lib/client/addMemberToCollection.ts @@ -2,29 +2,36 @@ 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, - memberUsername: string, + owner: User, + memberIdentifier: string, collection: CollectionIncludingMembersAndLinkCount, setMember: (newMember: Member) => null | undefined, t: TFunction<"translation", undefined> ) => { const checkIfMemberAlreadyExists = collection.members.find((e) => { const username = (e.user.username || "").toLowerCase(); - return username === memberUsername.toLowerCase(); + const email = (e.user.email || "").toLowerCase(); + + return ( + username === memberIdentifier.toLowerCase() || + email === memberIdentifier.toLowerCase() + ); }); if ( // no duplicate members !checkIfMemberAlreadyExists && // member can't be empty - memberUsername.trim() !== "" && + memberIdentifier.trim() !== "" && // member can't be the owner - memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase() + memberIdentifier.trim().toLowerCase() !== owner.username?.toLowerCase() && + memberIdentifier.trim().toLowerCase() !== owner.email?.toLowerCase() ) { // Lookup, get data/err, list ... - const user = await getPublicUserData(memberUsername.trim().toLowerCase()); + const user = await getPublicUserData(memberIdentifier.trim().toLowerCase()); if (user.username) { setMember({ @@ -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 ( + memberIdentifier.trim().toLowerCase() === owner.username?.toLowerCase() || + memberIdentifier.trim().toLowerCase() === owner.email?.toLowerCase() + ) toast.error(t("you_are_already_collection_owner")); }; diff --git a/lib/shared/schemaValidation.ts b/lib/shared/schemaValidation.ts index 955cfa8..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(), @@ -189,7 +190,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(), 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/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 51cbe2a..cc40dae 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: { @@ -1296,6 +1393,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { }, include: { subscriptions: true, + parentSubscription: true, }, }); 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..9cf4fc9 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; @@ -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 }); } @@ -41,6 +47,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { }, include: { subscriptions: true, + parentSubscription: true, }, }); @@ -58,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: @@ -73,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/api/v1/webhook/index.ts b/pages/api/v1/webhook/index.ts new file mode 100644 index 0000000..c2e9ef7 --- /dev/null +++ b/pages/api/v1/webhook/index.ts @@ -0,0 +1,119 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import Stripe from "stripe"; +import handleSubscription from "@/lib/api/stripe/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 as any)); + }); + + 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."); + } + + // 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" || data.status === "trialing", + 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/pages/member-onboarding.tsx b/pages/member-onboarding.tsx new file mode 100644 index 0000000..fce49b5 --- /dev/null +++ b/pages/member-onboarding.tsx @@ -0,0 +1,150 @@ +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, 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"; + +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(); + + 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) => { + setSubmitLoader(false); + toast.dismiss(load); + + if (error) { + toast.error(error.message); + } else { + toast.success(t("settings_applied")); + } + }, + } + ); + } else { + toast.error(t("please_fill_all_fields")); + } + } + + return ( + +
    +
    +

    + {t("invitation_accepted")} +

    + +
    + +

    + {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/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", + })}
    + +
    +

    + {t("manage_seats")} +

    +
    + +
    + +
    +
    + + + { + 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 h-[2.15rem] relative" + > +

    {t("invite_user")}

    + +
    +
    +
    + +
    + + + + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + + )} + + + + + {filteredUsers?.map((user, index) => ( + + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + + )} + + {user.id !== account.id && ( + + )} + + ))} + +
    {t("email")}{t("status")}{t("date_added")}
    +

    + {t("email")} +

    +

    {user.email}

    +
    +

    + {t("status")} +

    + {user.emailVerified ? ( +

    + {t("active")} +

    + ) : ( +

    + {t("pending")} +

    + )} +
    +

    + {t("date_added")} +

    +

    + {new Date(user.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

    +
    +
    +
    + +
    +
      + {!user.emailVerified ? ( +
    • +
      { + ( + document?.activeElement as HTMLElement + )?.blur(); + signIn("invite", { + email: user.email, + callbackUrl: "/member-onboarding", + redirect: false, + }).then(() => + toast.success(t("resend_invite_success")) + ); + }} + className="whitespace-nowrap" + > + {t("resend_invite")} +
      +
    • + ) : undefined} +
    • +
      { + (document?.activeElement as HTMLElement)?.blur(); + setDeleteUserModal({ + isOpen: true, + userId: user.id, + }); + }} + className="whitespace-nowrap" + > + {t("remove_user")} +
      +
    • +
    +
    +
    +
    +

    + {t("seats_purchased", { count: account?.subscription?.quantity })} +

    + {inviteModal && setInviteModal(false)} />} + {deleteUserModal.isOpen && deleteUserModal.userId && ( + setDeleteUserModal({ isOpen: false, userId: null })} + userId={deleteUserModal.userId} + /> + )} ); } diff --git a/pages/settings/delete.tsx b/pages/settings/delete.tsx index 0d11b87..0479d8f 100644 --- a/pages/settings/delete.tsx +++ b/pages/settings/delete.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import Button from "@/components/ui/Button"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useUser } from "@/hooks/store/user"; export default function Delete() { const [password, setPassword] = useState(""); @@ -15,6 +16,7 @@ export default function Delete() { const [submitLoader, setSubmitLoader] = useState(false); const { data } = useSession(); const { t } = useTranslation(); + const { data: user } = useUser(); const submit = async () => { const body = { @@ -83,7 +85,7 @@ export default function Delete() { /> - {process.env.NEXT_PUBLIC_STRIPE && ( + {process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
    {t("optional")} {t("feedback_help")} 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/subscribe.tsx b/pages/subscribe.tsx index 555798c..4c490f0 100644 --- a/pages/subscribe.tsx +++ b/pages/subscribe.tsx @@ -9,8 +9,6 @@ import getServerSideProps from "@/lib/client/getServerSideProps"; import { Trans, useTranslation } from "next-i18next"; import { useUser } from "@/hooks/store/user"; -const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; - export default function Subscribe() { const { t } = useTranslation(); const [submitLoader, setSubmitLoader] = useState(false); @@ -23,13 +21,14 @@ export default function Subscribe() { const { data: user = {} } = useUser(); useEffect(() => { - const hasInactiveSubscription = - user.id && !user.subscription?.active && stripeEnabled; - - if (session.status === "authenticated" && !hasInactiveSubscription) { + console.log("user", user); + if ( + session.status === "authenticated" && + user.id && + (user?.subscription?.active || user.parentSubscription?.active) + ) router.push("/dashboard"); - } - }, [session.status]); + }, [session.status, user]); async function submit() { setSubmitLoader(true); @@ -40,6 +39,8 @@ export default function Subscribe() { const data = await res.json(); router.push(data.response); + + toast.dismiss(redirectionToast); } 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); }; 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/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/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/migrations/20241027093300_remove_field/migration.sql b/prisma/migrations/20241027093300_remove_field/migration.sql new file mode 100644 index 0000000..2020a27 --- /dev/null +++ b/prisma/migrations/20241027093300_remove_field/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `teamRole` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "teamRole"; + +-- DropEnum +DROP TYPE "TeamRole"; diff --git a/prisma/migrations/20241027104510_remove_field/migration.sql b/prisma/migrations/20241027104510_remove_field/migration.sql new file mode 100644 index 0000000..a0dcd08 --- /dev/null +++ b/prisma/migrations/20241027104510_remove_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `teamId` on the `Collection` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Collection" DROP COLUMN "teamId"; diff --git a/prisma/migrations/20241030200844_createdby_fields_can_be_null/migration.sql b/prisma/migrations/20241030200844_createdby_fields_can_be_null/migration.sql new file mode 100644 index 0000000..85c04d9 --- /dev/null +++ b/prisma/migrations/20241030200844_createdby_fields_can_be_null/migration.sql @@ -0,0 +1,17 @@ +-- 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" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Link" ALTER COLUMN "createdById" DROP NOT NULL; + +-- 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8bd89eb..92801dc 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,14 @@ model User { image String? password String? locale String @default("en") + parentSubscription Subscription? @relation("ChildUsers", fields: [parentSubscriptionId], references: [id]) + parentSubscriptionId Int? 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[] @@ -105,6 +109,8 @@ model Collection { owner User @relation(fields: [ownerId], references: [id]) ownerId Int members UsersAndCollections[] + createdBy User? @relation("CreatedCollections", fields: [createdById], references: [id]) + createdById Int? links Link[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -132,7 +138,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[] @@ -171,8 +179,10 @@ model Subscription { stripeSubscriptionId String @unique currentPeriodStart DateTime currentPeriodEnd DateTime + 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 d07d66b..849cd11 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -318,7 +318,7 @@ "sharable_link": "Sharable Link", "copied": "Copied!", "members": "Members", - "members_username_placeholder": "Username (without the '@')", + "add_member_placeholder": "Add members by email or username", "owner": "Owner", "admin": "Admin", "contributor": "Contributor", @@ -397,5 +397,25 @@ "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.", - "browse_by_topic": "Browse by topic" + "browse_by_topic": "Browse by topic", + "team_management": "Team Management", + "invite_user": "Invite User", + "invite_users": "Invite Users", + "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 purchased and your account will automatically be billed for this addition.", + "send_invitation": "Send Invitation", + "learn_more": "Learn more", + "invitation_desc": "{{owner}} invited you to join Linkwarden. \nTo continue, please finish setting up your account.", + "invitation_accepted": "Invitation Accepted!", + "status": "Status", + "pending": "Pending", + "active": "Active", + "manage_seats": "Manage Seats", + "seats_purchased": "{{count}} seats purchased", + "date_added": "Date Added", + "resend_invite": "Resend Invitation", + "resend_invite_success": "Invitation Resent!", + "remove_user": "Remove User", + "continue_to_dashboard": "Continue to Dashboard", + "confirm_user_removal_desc": "They will need to have a subscription to access Linkwarden again." } \ 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 + + + + + + + + + + + + diff --git a/types/global.ts b/types/global.ts index 2be9f81..39da331 100644 --- a/types/global.ts +++ b/types/global.ts @@ -32,7 +32,7 @@ export interface Member { canCreate: boolean; canUpdate: boolean; canDelete: boolean; - user: OptionalExcluding; + user: OptionalExcluding; } export interface CollectionIncludingMembersAndLinkCount