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,
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 (
<AppShell>
<PageHeaderContainer>
<Breadcrumb>
<BreadcrumbItem>
<ProjectBreadcrumbContents />
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<Text>Project Settings</Text>
</BreadcrumbItem>
</Breadcrumb>
</PageHeaderContainer>
<VStack px={8} pt={4} alignItems="flex-start" spacing={4}>
<VStack spacing={0} alignItems="flex-start">
<Text fontSize="2xl" fontWeight="bold">
Project Settings
</Text>
<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
<>
<AppShell>
<PageHeaderContainer>
<Breadcrumb>
<BreadcrumbItem>
<ProjectBreadcrumbContents />
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<Text>Project Settings</Text>
</BreadcrumbItem>
</Breadcrumb>
</PageHeaderContainer>
<VStack px={8} pt={4} alignItems="flex-start" spacing={4}>
<VStack spacing={0} alignItems="flex-start">
<Text fontSize="2xl" fontWeight="bold">
Project Settings
</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.
Configure your project settings. These settings only apply to {selectedOrg?.name}.
</Text>
</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>
</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 { 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,
},
});
}),
});

View File

@@ -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) {