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,72 +45,97 @@ export default function Settings() {
setName(selectedOrg?.name); setName(selectedOrg?.name);
}, [selectedOrg?.name]); }, [selectedOrg?.name]);
const deleteProjectOpen = useDisclosure();
return ( return (
<AppShell> <>
<PageHeaderContainer> <AppShell>
<Breadcrumb> <PageHeaderContainer>
<BreadcrumbItem> <Breadcrumb>
<ProjectBreadcrumbContents /> <BreadcrumbItem>
</BreadcrumbItem> <ProjectBreadcrumbContents />
<BreadcrumbItem isCurrentPage> </BreadcrumbItem>
<Text>Project Settings</Text> <BreadcrumbItem isCurrentPage>
</BreadcrumbItem> <Text>Project Settings</Text>
</Breadcrumb> </BreadcrumbItem>
</PageHeaderContainer> </Breadcrumb>
<VStack px={8} pt={4} alignItems="flex-start" spacing={4}> </PageHeaderContainer>
<VStack spacing={0} alignItems="flex-start"> <VStack px={8} pt={4} alignItems="flex-start" spacing={4}>
<Text fontSize="2xl" fontWeight="bold"> <VStack spacing={0} alignItems="flex-start">
Project Settings <Text fontSize="2xl" fontWeight="bold">
</Text> Project Settings
<Text fontSize="sm">
Configure your project settings. These settings only apply to {selectedOrg?.name}.
</Text>
</VStack>
<VStack
w="full"
alignItems="flex-start"
borderWidth={1}
borderRadius={4}
borderColor="gray.300"
p={6}
spacing={6}
>
<VStack alignItems="flex-start" w="full">
<Text fontWeight="bold" fontSize="xl">
Display Name
</Text> </Text>
<Input
w="full"
maxW={600}
value={name}
onChange={(e) => setName(e.target.value)}
borderColor="gray.300"
/>
<Button
isDisabled={!name || name === selectedOrg?.name}
colorScheme="orange"
borderRadius={4}
mt={2}
_disabled={{
opacity: 0.6,
}}
onClick={onSaveName}
>
Rename Project
</Button>
</VStack>
<Divider backgroundColor="gray.300" />
<VStack alignItems="flex-start">
<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, Configure your project settings. These settings only apply to {selectedOrg?.name}.
or use it directly in your code.
</Text> </Text>
</VStack> </VStack>
<CopiableCode code={`OPENPIPE_API_KEY=${apiKey}`} /> <VStack
w="full"
alignItems="flex-start"
borderWidth={1}
borderRadius={4}
borderColor="gray.300"
p={6}
spacing={6}
>
<VStack alignItems="flex-start" w="full">
<Text fontWeight="bold" fontSize="xl">
Display Name
</Text>
<Input
w="full"
maxW={600}
value={name}
onChange={(e) => setName(e.target.value)}
borderColor="gray.300"
/>
<Button
isDisabled={!name || name === selectedOrg?.name}
colorScheme="orange"
borderRadius={4}
mt={2}
_disabled={{
opacity: 0.6,
}}
onClick={onSaveName}
>
Rename Project
</Button>
</VStack>
<Divider backgroundColor="gray.300" />
<VStack alignItems="flex-start">
<Subtitle>Project API Key</Subtitle>
<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, or use it directly in
your code.
</Text>
</VStack>
<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([
where: { prisma.organization.findUnique({
id: input.id, where: {
}, id: input.id,
include: { },
apiKeys: true, 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 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) {