From 6d32f1c06edb76040965dd7258717ba93b4298e8 Mon Sep 17 00:00:00 2001 From: David Corbitt Date: Mon, 7 Aug 2023 21:45:21 -0700 Subject: [PATCH] Allow admins to delete projects --- .../projectSettings/DeleteProjectDialog.tsx | 89 +++++++++++ app/src/pages/settings/index.tsx | 150 +++++++++++------- .../api/routers/organizations.router.ts | 53 +++++-- app/src/utils/accessControl.ts | 21 +++ 4 files changed, 244 insertions(+), 69 deletions(-) create mode 100644 app/src/components/projectSettings/DeleteProjectDialog.tsx diff --git a/app/src/components/projectSettings/DeleteProjectDialog.tsx b/app/src/components/projectSettings/DeleteProjectDialog.tsx new file mode 100644 index 0000000..1f586f2 --- /dev/null +++ b/app/src/components/projectSettings/DeleteProjectDialog.tsx @@ -0,0 +1,89 @@ +import { + Button, + AlertDialog, + AlertDialogBody, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogContent, + AlertDialogOverlay, + Input, + Text, + VStack, + Box, + Spinner, +} from "@chakra-ui/react"; + +import { useRouter } from "next/router"; +import { useRef, useState } from "react"; +import { api } from "~/utils/api"; +import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks"; + +export const DeleteProjectDialog = ({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) => { + const selectedOrg = useSelectedOrg(); + const deleteMutation = api.organizations.delete.useMutation(); + const utils = api.useContext(); + const router = useRouter(); + + const cancelRef = useRef(null); + + const [onDeleteConfirm, isDeleting] = useHandledAsyncCallback(async () => { + if (!selectedOrg.data?.id) return; + await deleteMutation.mutateAsync({ id: selectedOrg.data.id }); + await utils.organizations.list.invalidate(); + await router.push({ pathname: "/home" }); + onClose(); + }, [deleteMutation, selectedOrg, router]); + + const [nameToDelete, setNameToDelete] = useState(""); + + return ( + + + + + Delete Project + + + + + + If you delete this project all the associated data and experiments will be deleted + as well. If you are sure that you want to delete this project, please type the name + of the project below. + + + {selectedOrg.data?.name} + + setNameToDelete(e.target.value)} + /> + + + + + + + + + + + ); +}; diff --git a/app/src/pages/settings/index.tsx b/app/src/pages/settings/index.tsx index 8ca407a..cb1139e 100644 --- a/app/src/pages/settings/index.tsx +++ b/app/src/pages/settings/index.tsx @@ -4,11 +4,15 @@ import { Text, type TextProps, VStack, + HStack, Input, Button, Divider, + Icon, + useDisclosure, } from "@chakra-ui/react"; import { useEffect, useState } from "react"; +import { BsTrash } from "react-icons/bs"; import AppShell from "~/components/nav/AppShell"; import PageHeaderContainer from "~/components/nav/PageHeaderContainer"; @@ -16,6 +20,7 @@ import { api } from "~/utils/api"; import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks"; import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents"; import CopiableCode from "~/components/CopiableCode"; +import { DeleteProjectDialog } from "~/components/projectSettings/DeleteProjectDialog"; export default function Settings() { const utils = api.useContext(); @@ -40,72 +45,97 @@ export default function Settings() { setName(selectedOrg?.name); }, [selectedOrg?.name]); + const deleteProjectOpen = useDisclosure(); + return ( - - - - - - - - Project Settings - - - - - - - Project Settings - - - Configure your project settings. These settings only apply to {selectedOrg?.name}. - - - - - - Display Name + <> + + + + + + + + Project Settings + + + + + + + Project Settings - setName(e.target.value)} - borderColor="gray.300" - /> - - - - - Project API Key - Use your project API key to authenticate your requests when sending data to OpenPipe. You can set this key in your environment variables, - or use it directly in your code. + Configure your project settings. These settings only apply to {selectedOrg?.name}. - + + + + Display Name + + setName(e.target.value)} + borderColor="gray.300" + /> + + + + + Project API Key + + Use your project API key to authenticate your requests when sending data to + OpenPipe. You can set this key in your environment variables, or use it directly in + your code. + + + + + + Danger Zone + + Permanently delete your project and all of its data. This action cannot be undone. + + + + Delete {selectedOrg?.name} + + + - - + + + ); } diff --git a/app/src/server/api/routers/organizations.router.ts b/app/src/server/api/routers/organizations.router.ts index 293e0f6..9e427c5 100644 --- a/app/src/server/api/routers/organizations.router.ts +++ b/app/src/server/api/routers/organizations.router.ts @@ -1,10 +1,15 @@ +import { TRPCError } from "@trpc/server"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { prisma } from "~/server/db"; import { generateApiKey } from "~/server/utils/generateApiKey"; -import { requireCanModifyOrganization, requireNothing } from "~/utils/accessControl"; +import { + requireCanModifyOrganization, + requireIsOrgAdmin, + requireNothing, +} from "~/utils/accessControl"; export const organizationsRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { @@ -57,14 +62,34 @@ export const organizationsRouter = createTRPCRouter({ }), get: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => { requireNothing(ctx); - return await prisma.organization.findUnique({ - where: { - id: input.id, - }, - include: { - apiKeys: true, - }, - }); + const [org, userRole] = await prisma.$transaction([ + prisma.organization.findUnique({ + where: { + id: input.id, + }, + include: { + apiKeys: true, + }, + }), + prisma.organizationUser.findFirst({ + where: { + userId: ctx.session.user.id, + organizationId: input.id, + role: { + in: ["ADMIN", "MEMBER"], + }, + }, + }), + ]); + + if (!org) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + return { + ...org, + role: userRole?.role ?? null, + }; }), update: protectedProcedure .input(z.object({ id: z.string(), updates: z.object({ name: z.string() }) })) @@ -108,4 +133,14 @@ export const organizationsRouter = createTRPCRouter({ ]); return newOrg; }), + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + await requireIsOrgAdmin(input.id, ctx); + return await prisma.organization.delete({ + where: { + id: input.id, + }, + }); + }), }); diff --git a/app/src/utils/accessControl.ts b/app/src/utils/accessControl.ts index ad55904..f4d7aac 100644 --- a/app/src/utils/accessControl.ts +++ b/app/src/utils/accessControl.ts @@ -16,6 +16,27 @@ export const requireNothing = (ctx: TRPCContext) => { ctx.markAccessControlRun(); }; +export const requireIsOrgAdmin = async (organizationId: string, ctx: TRPCContext) => { + const userId = ctx.session?.user.id; + if (!userId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const isAdmin = await prisma.organizationUser.findFirst({ + where: { + userId, + organizationId, + role: "ADMIN", + }, + }); + + if (!isAdmin) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + ctx.markAccessControlRun(); +}; + export const requireCanViewOrganization = async (organizationId: string, ctx: TRPCContext) => { const userId = ctx.session?.user.id; if (!userId) {