Allow admins to delete projects
This commit is contained in:
89
app/src/components/projectSettings/DeleteProjectDialog.tsx
Normal file
89
app/src/components/projectSettings/DeleteProjectDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user