From 665019dc59c680a9928e9fd52962fa703ab2e78a Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 29 Oct 2024 18:08:47 -0400 Subject: [PATCH] finalizing team support --- components/ModalContent/DeleteUserModal.tsx | 25 +- .../EditCollectionSharingModal.tsx | 6 +- components/ModalContent/InviteModal.tsx | 6 +- components/ProfileDropdown.tsx | 7 +- components/ui/Divider.tsx | 12 + layouts/AuthRedirect.tsx | 1 - .../collectionId/updateCollectionById.ts | 8 +- .../controllers/public/users/getPublicUser.ts | 16 +- lib/api/controllers/users/getUsers.ts | 80 +++++-- .../users/userId/deleteUserById.ts | 101 ++++++-- .../controllers/users/userId/getUserById.ts | 1 + .../users/userId/updateUserById.ts | 1 + lib/client/addMemberToCollection.ts | 19 +- pages/admin.tsx | 3 +- pages/api/v1/users/[id].ts | 24 +- pages/api/v1/users/index.ts | 5 +- pages/member-onboarding.tsx | 5 +- pages/settings/access-tokens.tsx | 12 +- pages/settings/billing.tsx | 225 +++++++++++++++++- pages/team.tsx | 108 --------- .../20241027093300_remove_field/migration.sql | 11 + .../20241027104510_remove_field/migration.sql | 8 + prisma/schema.prisma | 7 - public/locales/en/common.json | 19 +- types/global.ts | 2 +- 25 files changed, 511 insertions(+), 201 deletions(-) create mode 100644 components/ui/Divider.tsx delete mode 100644 pages/team.tsx create mode 100644 prisma/migrations/20241027093300_remove_field/migration.sql create mode 100644 prisma/migrations/20241027104510_remove_field/migration.sql diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx index 77931c6..c7d5a4a 100644 --- a/components/ModalContent/DeleteUserModal.tsx +++ b/components/ModalContent/DeleteUserModal.tsx @@ -3,6 +3,7 @@ import Button from "../ui/Button"; import { useTranslation } from "next-i18next"; import { useDeleteUser } from "@/hooks/store/admin/users"; import { useState } from "react"; +import { useSession } from "next-auth/react"; type Props = { onClose: Function; @@ -30,25 +31,33 @@ export default function DeleteUserModal({ onClose, userId }: Props) { } }; + const { data } = useSession(); + const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN); + return ( -

{t("delete_user")}

+

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

{t("confirm_user_deletion")}

+

{t("confirm_user_removal_desc")}

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