Allow admins to delete projects

This commit is contained in:
David Corbitt
2023-08-07 21:45:21 -07:00
parent 8fed9730da
commit 6d32f1c06e
4 changed files with 244 additions and 69 deletions

View File

@@ -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<HTMLButtonElement>(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 (
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Project
</AlertDialogHeader>
<AlertDialogBody>
<VStack spacing={4} alignItems="flex-start">
<Text>
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.
</Text>
<Box bgColor="orange.100" w="full" p={2} borderRadius={4}>
<Text fontFamily="inconsolata">{selectedOrg.data?.name}</Text>
</Box>
<Input
placeholder={selectedOrg.data?.name}
value={nameToDelete}
onChange={(e) => setNameToDelete(e.target.value)}
/>
</VStack>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button
colorScheme="red"
onClick={onDeleteConfirm}
ml={3}
isDisabled={nameToDelete !== selectedOrg.data?.name}
w={20}
>
{isDeleting ? <Spinner /> : "Delete"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@@ -4,11 +4,15 @@ import {
Text, Text,
type TextProps, type TextProps,
VStack, VStack,
HStack,
Input, Input,
Button, Button,
Divider, Divider,
Icon,
useDisclosure,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BsTrash } from "react-icons/bs";
import AppShell from "~/components/nav/AppShell"; import AppShell from "~/components/nav/AppShell";
import PageHeaderContainer from "~/components/nav/PageHeaderContainer"; import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
@@ -16,6 +20,7 @@ import { api } from "~/utils/api";
import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks"; import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks";
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents"; import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
import CopiableCode from "~/components/CopiableCode"; import CopiableCode from "~/components/CopiableCode";
import { DeleteProjectDialog } from "~/components/projectSettings/DeleteProjectDialog";
export default function Settings() { export default function Settings() {
const utils = api.useContext(); const utils = api.useContext();
@@ -40,7 +45,10 @@ export default function Settings() {
setName(selectedOrg?.name); setName(selectedOrg?.name);
}, [selectedOrg?.name]); }, [selectedOrg?.name]);
const deleteProjectOpen = useDisclosure();
return ( return (
<>
<AppShell> <AppShell>
<PageHeaderContainer> <PageHeaderContainer>
<Breadcrumb> <Breadcrumb>
@@ -98,14 +106,36 @@ export default function Settings() {
<VStack alignItems="flex-start"> <VStack alignItems="flex-start">
<Subtitle>Project API Key</Subtitle> <Subtitle>Project API Key</Subtitle>
<Text fontSize="sm"> <Text fontSize="sm">
Use your project API key to authenticate your requests when sending data to OpenPipe. You can set this key in your environment variables, Use your project API key to authenticate your requests when sending data to
or use it directly in your code. OpenPipe. You can set this key in your environment variables, or use it directly in
your code.
</Text> </Text>
</VStack> </VStack>
<CopiableCode code={`OPENPIPE_API_KEY=${apiKey}`} /> <CopiableCode code={`OPENPIPE_API_KEY=${apiKey}`} />
<Divider />
<VStack alignItems="flex-start">
<Subtitle color="red.600">Danger Zone</Subtitle>
<Text fontSize="sm">
Permanently delete your project and all of its data. This action cannot be undone.
</Text>
<HStack
as={Button}
isDisabled={selectedOrg?.role !== "ADMIN"}
colorScheme="red"
variant="outline"
borderRadius={4}
mt={2}
onClick={deleteProjectOpen.onOpen}
>
<Icon as={BsTrash} />
<Text>Delete {selectedOrg?.name}</Text>
</HStack>
</VStack>
</VStack> </VStack>
</VStack> </VStack>
</AppShell> </AppShell>
<DeleteProjectDialog isOpen={deleteProjectOpen.isOpen} onClose={deleteProjectOpen.onClose} />
</>
); );
} }

View File

@@ -1,10 +1,15 @@
import { TRPCError } from "@trpc/server";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db"; import { prisma } from "~/server/db";
import { generateApiKey } from "~/server/utils/generateApiKey"; import { generateApiKey } from "~/server/utils/generateApiKey";
import { requireCanModifyOrganization, requireNothing } from "~/utils/accessControl"; import {
requireCanModifyOrganization,
requireIsOrgAdmin,
requireNothing,
} from "~/utils/accessControl";
export const organizationsRouter = createTRPCRouter({ export const organizationsRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => { 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 }) => { get: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
requireNothing(ctx); requireNothing(ctx);
return await prisma.organization.findUnique({ const [org, userRole] = await prisma.$transaction([
prisma.organization.findUnique({
where: { where: {
id: input.id, id: input.id,
}, },
include: { include: {
apiKeys: true, 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 update: protectedProcedure
.input(z.object({ id: z.string(), updates: z.object({ name: z.string() }) })) .input(z.object({ id: z.string(), updates: z.object({ name: z.string() }) }))
@@ -108,4 +133,14 @@ export const organizationsRouter = createTRPCRouter({
]); ]);
return newOrg; 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,
},
});
}),
}); });

View File

@@ -16,6 +16,27 @@ export const requireNothing = (ctx: TRPCContext) => {
ctx.markAccessControlRun(); 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) => { export const requireCanViewOrganization = async (organizationId: string, ctx: TRPCContext) => {
const userId = ctx.session?.user.id; const userId = ctx.session?.user.id;
if (!userId) { if (!userId) {