Invite members (#161)

* Allow user invitations

* Restyle inviting members

* Remove annoying comment

* Add page for accepting an invitation

* Send invitation email with Brevo

* Prevent admins from removing personal project users

* Mark access ceontrol for cancelProjectInvitation

* Make RadioGroup controlled

* Shorten form helper text

* Use nodemailer to send emails

* Update .env.example
This commit is contained in:
arcticfly
2023-08-16 17:25:31 -07:00
committed by GitHub
parent 0fba2c9ee7
commit 809ef04dc1
19 changed files with 1152 additions and 45 deletions

View File

@@ -34,3 +34,9 @@ GITHUB_CLIENT_SECRET="your_secret"
OPENPIPE_BASE_URL="http://localhost:3000/api/v1"
OPENPIPE_API_KEY="your_key"
SENDER_EMAIL="placeholder"
SMTP_HOST="placeholder"
SMTP_PORT="placeholder"
SMTP_LOGIN="placeholder"
SMTP_PASSWORD="placeholder"

View File

@@ -23,6 +23,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/experiments/[id]", { "id": string }>
| StaticRoute<"/experiments">
| StaticRoute<"/">
| DynamicRoute<"/invitations/[invitationToken]", { "invitationToken": string }>
| StaticRoute<"/project/settings">
| StaticRoute<"/request-logs">
| StaticRoute<"/sentry-example-page">

View File

@@ -37,6 +37,7 @@
"@monaco-editor/loader": "^1.3.3",
"@next-auth/prisma-adapter": "^1.0.5",
"@prisma/client": "^4.14.0",
"@sendinblue/client": "^3.3.1",
"@sentry/nextjs": "^7.61.0",
"@t3-oss/env-nextjs": "^0.3.1",
"@tabler/icons-react": "^2.22.0",
@@ -66,11 +67,13 @@
"kysely": "^0.26.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.265.0",
"marked": "^7.0.3",
"next": "^13.4.2",
"next-auth": "^4.22.1",
"next-query-params": "^4.2.3",
"nextjs-cors": "^2.1.2",
"nextjs-routes": "^2.0.1",
"nodemailer": "^6.9.4",
"openai": "4.0.0-beta.7",
"openpipe": "workspace:*",
"pg": "^8.11.2",
@@ -114,6 +117,7 @@
"@types/json-schema": "^7.0.12",
"@types/lodash-es": "^4.17.8",
"@types/node": "^18.16.0",
"@types/nodemailer": "^6.4.9",
"@types/pg": "^8.10.2",
"@types/pluralize": "^0.0.30",
"@types/prismjs": "^1.26.0",

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "UserInvitation" (
"id" UUID NOT NULL,
"projectId" UUID NOT NULL,
"email" TEXT NOT NULL,
"role" "ProjectUserRole" NOT NULL,
"invitationToken" TEXT NOT NULL,
"senderId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserInvitation_invitationToken_key" ON "UserInvitation"("invitationToken");
-- CreateIndex
CREATE UNIQUE INDEX "UserInvitation_projectId_email_key" ON "UserInvitation"("projectId", "email");
-- AddForeignKey
ALTER TABLE "UserInvitation" ADD CONSTRAINT "UserInvitation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserInvitation" ADD CONSTRAINT "UserInvitation_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -207,13 +207,14 @@ model Project {
personalProjectUserId String? @unique @db.Uuid
personalProjectUser User? @relation(fields: [personalProjectUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectUsers ProjectUser[]
experiments Experiment[]
datasets Dataset[]
loggedCalls LoggedCall[]
apiKeys ApiKey[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectUsers ProjectUser[]
projectUserInvitations UserInvitation[]
experiments Experiment[]
datasets Dataset[]
loggedCalls LoggedCall[]
apiKeys ApiKey[]
}
enum ProjectUserRole {
@@ -390,16 +391,33 @@ model User {
role UserRole @default(USER)
accounts Account[]
sessions Session[]
projectUsers ProjectUser[]
projects Project[]
worldChampEntrant WorldChampEntrant?
accounts Account[]
sessions Session[]
projectUsers ProjectUser[]
projects Project[]
worldChampEntrant WorldChampEntrant?
sentUserInvitations UserInvitation[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}
model UserInvitation {
id String @id @default(uuid()) @db.Uuid
projectId String @db.Uuid
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
email String
role ProjectUserRole
invitationToken String @unique
senderId String @db.Uuid
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@unique([projectId, email])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String @unique

View File

@@ -0,0 +1,128 @@
import {
Button,
FormControl,
FormLabel,
Input,
FormHelperText,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spinner,
Text,
VStack,
RadioGroup,
Radio,
} from "@chakra-ui/react";
import { useState, useEffect } from "react";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError";
import { type ProjectUserRole } from "@prisma/client";
export const InviteMemberModal = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const selectedProject = useSelectedProject().data;
const utils = api.useContext();
const [email, setEmail] = useState("");
const [role, setRole] = useState<ProjectUserRole>("MEMBER");
useEffect(() => {
setEmail("");
setRole("MEMBER");
}, [isOpen]);
const emailIsValid = !email || !email.match(/.+@.+\..+/);
const inviteMemberMutation = api.users.inviteToProject.useMutation();
const [inviteMember, isInviting] = useHandledAsyncCallback(async () => {
if (!selectedProject?.id || !role) return;
const resp = await inviteMemberMutation.mutateAsync({
projectId: selectedProject.id,
email,
role,
});
if (maybeReportError(resp)) return;
await utils.projects.get.invalidate();
onClose();
}, [inviteMemberMutation, email, role, selectedProject?.id, onClose]);
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent w={1200}>
<ModalHeader>
<HStack>
<Text>Invite Member</Text>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={8} alignItems="flex-start">
<Text>
Invite a new member to <b>{selectedProject?.name}</b>.
</Text>
<RadioGroup
value={role}
onChange={(e) => setRole(e as ProjectUserRole)}
colorScheme="orange"
>
<VStack w="full" alignItems="flex-start">
<Radio value="MEMBER">
<Text fontSize="sm">MEMBER</Text>
</Radio>
<Radio value="ADMIN">
<Text fontSize="sm">ADMIN</Text>
</Radio>
</VStack>
</RadioGroup>
<FormControl>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || e.shiftKey)) {
e.preventDefault();
e.currentTarget.blur();
inviteMember();
}
}}
/>
<FormHelperText>Enter the email of the person you want to invite.</FormHelperText>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter mt={4}>
<HStack>
<Button colorScheme="gray" onClick={onClose} minW={24}>
<Text>Cancel</Text>
</Button>
<Button
colorScheme="orange"
onClick={inviteMember}
minW={24}
isDisabled={emailIsValid || isInviting}
>
{isInviting ? <Spinner boxSize={4} /> : <Text>Send Invitation</Text>}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,119 @@
import { useState } from "react";
import {
Table,
Thead,
Tr,
Th,
Tbody,
Td,
IconButton,
useDisclosure,
Text,
Button,
} from "@chakra-ui/react";
import { useSession } from "next-auth/react";
import { BsTrash } from "react-icons/bs";
import { type User } from "@prisma/client";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import { InviteMemberModal } from "./InviteMemberModal";
import { RemoveMemberDialog } from "./RemoveMemberDialog";
import { api } from "~/utils/api";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError";
const MemberTable = () => {
const selectedProject = useSelectedProject().data;
const session = useSession().data;
const utils = api.useContext();
const [memberToRemove, setMemberToRemove] = useState<User | null>(null);
const inviteMemberModal = useDisclosure();
const cancelInvitationMutation = api.users.cancelProjectInvitation.useMutation();
const [cancelInvitation, isCancelling] = useHandledAsyncCallback(
async (invitationToken: string) => {
if (!selectedProject?.id) return;
const resp = await cancelInvitationMutation.mutateAsync({
invitationToken,
});
if (maybeReportError(resp)) return;
await utils.projects.get.invalidate();
},
[selectedProject?.id, cancelInvitationMutation],
);
return (
<>
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
{selectedProject?.role === "ADMIN" && <Th />}
</Tr>
</Thead>
<Tbody>
{selectedProject?.projectUsers.map((member) => {
return (
<Tr key={member.id}>
<Td>
<Text fontWeight="bold">{member.user.name}</Text>
</Td>
<Td>{member.user.email}</Td>
<Td fontSize="sm">{member.role}</Td>
{selectedProject.role === "ADMIN" && (
<Td textAlign="end">
{member.user.id !== session?.user?.id &&
member.user.id !== selectedProject.personalProjectUserId && (
<IconButton
aria-label="Remove member"
colorScheme="red"
icon={<BsTrash />}
onClick={() => setMemberToRemove(member.user)}
/>
)}
</Td>
)}
</Tr>
);
})}
{selectedProject?.projectUserInvitations?.map((invitation) => {
return (
<Tr key={invitation.id}>
<Td>
<Text as="i">Invitation pending</Text>
</Td>
<Td>{invitation.email}</Td>
<Td fontSize="sm">{invitation.role}</Td>
{selectedProject.role === "ADMIN" && (
<Td textAlign="end">
<Button
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => cancelInvitation(invitation.invitationToken)}
isLoading={isCancelling}
>
Cancel
</Button>
</Td>
)}
</Tr>
);
})}
</Tbody>
</Table>
<InviteMemberModal isOpen={inviteMemberModal.isOpen} onClose={inviteMemberModal.onClose} />
<RemoveMemberDialog
member={memberToRemove}
isOpen={!!memberToRemove}
onClose={() => setMemberToRemove(null)}
/>
</>
);
};
export default MemberTable;

View File

@@ -0,0 +1,71 @@
import {
Button,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Text,
VStack,
Spinner,
} from "@chakra-ui/react";
import { type User } from "@prisma/client";
import { useRouter } from "next/router";
import { useRef } from "react";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
export const RemoveMemberDialog = ({
isOpen,
onClose,
member,
}: {
isOpen: boolean;
onClose: () => void;
member: User | null;
}) => {
const selectedProject = useSelectedProject();
const removeUserMutation = api.users.removeUserFromProject.useMutation();
const utils = api.useContext();
const router = useRouter();
const cancelRef = useRef<HTMLButtonElement>(null);
const [onRemoveConfirm, isRemoving] = useHandledAsyncCallback(async () => {
if (!selectedProject.data?.id || !member?.id) return;
await removeUserMutation.mutateAsync({ projectId: selectedProject.data.id, userId: member.id });
await utils.projects.get.invalidate();
onClose();
}, [removeUserMutation, selectedProject, router]);
return (
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Remove Member
</AlertDialogHeader>
<AlertDialogBody>
<VStack spacing={4} alignItems="flex-start">
<Text>
Are you sure you want to remove <b>{member?.name}</b> from the project?
</Text>
</VStack>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" onClick={onRemoveConfirm} ml={3} w={20}>
{isRemoving ? <Spinner /> : "Remove"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@@ -21,6 +21,11 @@ export const env = createEnv({
ANTHROPIC_API_KEY: z.string().default("placeholder"),
SENTRY_AUTH_TOKEN: z.string().optional(),
OPENPIPE_API_KEY: z.string().optional(),
SENDER_EMAIL: z.string().default("placeholder"),
SMTP_HOST: z.string().default("placeholder"),
SMTP_PORT: z.string().default("placeholder"),
SMTP_LOGIN: z.string().default("placeholder"),
SMTP_PASSWORD: z.string().default("placeholder"),
},
/**
@@ -58,6 +63,11 @@ export const env = createEnv({
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
OPENPIPE_API_KEY: process.env.OPENPIPE_API_KEY,
NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS: process.env.NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS,
SENDER_EMAIL: process.env.SENDER_EMAIL,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_LOGIN: process.env.SMTP_LOGIN,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.

View File

@@ -26,26 +26,6 @@ import Head from "next/head";
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
// TODO: import less to fix deployment with server side props
// export const getServerSideProps = async (context: GetServerSidePropsContext<{ id: string }>) => {
// const experimentId = context.params?.id as string;
// const helpers = createServerSideHelpers({
// router: appRouter,
// ctx: createInnerTRPCContext({ session: null }),
// transformer: superjson, // optional - adds superjson serialization
// });
// // prefetch query
// await helpers.experiments.stats.prefetch({ id: experimentId });
// return {
// props: {
// trpcState: helpers.dehydrate(),
// },
// };
// };
export default function Experiment() {
const router = useRouter();
const utils = api.useContext();

View File

@@ -0,0 +1,110 @@
import { Center, Text, VStack, HStack, Button, Card } from "@chakra-ui/react";
import { useRouter } from "next/router";
import AppShell from "~/components/nav/AppShell";
import { api } from "~/utils/api";
import { useHandledAsyncCallback } from "~/utils/hooks";
import { useAppStore } from "~/state/store";
import { useSyncVariantEditor } from "~/state/sync";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError";
export default function Invitation() {
const router = useRouter();
const utils = api.useContext();
useSyncVariantEditor();
const setSelectedProjectId = useAppStore((state) => state.setSelectedProjectId);
const invitationToken = router.query.invitationToken as string | undefined;
const invitation = api.users.getProjectInvitation.useQuery(
{ invitationToken: invitationToken as string },
{ enabled: !!invitationToken },
);
const cancelMutation = api.users.cancelProjectInvitation.useMutation();
const [declineInvitation, isDeclining] = useHandledAsyncCallback(async () => {
if (invitationToken) {
await cancelMutation.mutateAsync({
invitationToken,
});
await router.replace("/");
}
}, [cancelMutation, invitationToken]);
const acceptMutation = api.users.acceptProjectInvitation.useMutation();
const [acceptInvitation, isAccepting] = useHandledAsyncCallback(async () => {
if (invitationToken) {
const resp = await acceptMutation.mutateAsync({
invitationToken,
});
if (!maybeReportError(resp) && resp) {
await utils.projects.list.invalidate();
setSelectedProjectId(resp.payload);
}
await router.replace("/");
}
}, [acceptMutation, invitationToken]);
if (invitation.isLoading) {
return (
<AppShell requireAuth title="Loading...">
<Center h="full">
<Text>Loading...</Text>
</Center>
</AppShell>
);
}
if (!invitationToken || !invitation.data) {
return (
<AppShell requireAuth title="Invalid invitation token">
<Center h="full">
<Text>
The invitation you've received is invalid or expired. Please ask your project admin for
a new token.
</Text>
</Center>
</AppShell>
);
}
return (
<>
<AppShell requireAuth title="Invitation">
<Center h="full">
<Card>
<VStack
spacing={8}
w="full"
maxW="2xl"
p={16}
borderWidth={1}
borderRadius={8}
bgColor="white"
>
<Text fontSize="lg" fontWeight="bold">
You're invited! 🎉
</Text>
<Text textAlign="center">
You've been invited to join <b>{invitation.data.project.name}</b> by{" "}
<b>
{invitation.data.sender.name} ({invitation.data.sender.email})
</b>
.
</Text>
<HStack spacing={4}>
<Button colorScheme="gray" isLoading={isDeclining} onClick={declineInvitation}>
Decline
</Button>
<Button colorScheme="orange" isLoading={isAccepting} onClick={acceptInvitation}>
Accept
</Button>
</HStack>
</VStack>
</Card>
</Center>
</AppShell>
</>
);
}

View File

@@ -9,9 +9,11 @@ import {
Divider,
Icon,
useDisclosure,
Box,
Tooltip,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { BsTrash } from "react-icons/bs";
import { BsPlus, BsTrash } from "react-icons/bs";
import AppShell from "~/components/nav/AppShell";
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
@@ -21,6 +23,8 @@ import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContent
import CopiableCode from "~/components/CopiableCode";
import { DeleteProjectDialog } from "~/components/projectSettings/DeleteProjectDialog";
import AutoResizeTextArea from "~/components/AutoResizeTextArea";
import MemberTable from "~/components/projectSettings/MemberTable";
import { InviteMemberModal } from "~/components/projectSettings/InviteMemberModal";
export default function Settings() {
const utils = api.useContext();
@@ -50,11 +54,12 @@ export default function Settings() {
setName(selectedProject?.name);
}, [selectedProject?.name]);
const deleteProjectOpen = useDisclosure();
const inviteMemberModal = useDisclosure();
const deleteProjectDialog = useDisclosure();
return (
<>
<AppShell>
<AppShell requireAuth>
<PageHeaderContainer>
<Breadcrumb>
<BreadcrumbItem>
@@ -109,6 +114,37 @@ export default function Settings() {
</Button>
</VStack>
<Divider backgroundColor="gray.300" />
<VStack w="full" alignItems="flex-start">
<Subtitle>Project Members</Subtitle>
<Text fontSize="sm">
Add members to your project to allow them to view and edit your project's data.
</Text>
<Box mt={4} w="full">
<MemberTable />
</Box>
<Tooltip
isDisabled={selectedProject?.role === "ADMIN"}
label="Only admins can invite new members"
hasArrow
>
<Button
variant="outline"
colorScheme="orange"
borderRadius={4}
onClick={inviteMemberModal.onOpen}
mt={2}
_disabled={{
opacity: 0.6,
}}
isDisabled={selectedProject?.role !== "ADMIN"}
>
<Icon as={BsPlus} boxSize={5} />
<Text>Invite New Member</Text>
</Button>
</Tooltip>
</VStack>
<Divider backgroundColor="gray.300" />
<VStack alignItems="flex-start">
<Subtitle>Project API Key</Subtitle>
<Text fontSize="sm">
@@ -141,7 +177,7 @@ export default function Settings() {
borderRadius={4}
mt={2}
height="auto"
onClick={deleteProjectOpen.onOpen}
onClick={deleteProjectDialog.onOpen}
>
<Icon as={BsTrash} />
<Text overflowWrap="break-word" whiteSpace="normal" py={2}>
@@ -153,7 +189,11 @@ export default function Settings() {
</VStack>
</VStack>
</AppShell>
<DeleteProjectDialog isOpen={deleteProjectOpen.isOpen} onClose={deleteProjectOpen.onClose} />
<InviteMemberModal isOpen={inviteMemberModal.isOpen} onClose={inviteMemberModal.onClose} />
<DeleteProjectDialog
isOpen={deleteProjectDialog.isOpen}
onClose={deleteProjectDialog.onClose}
/>
</>
);
}

View File

@@ -11,6 +11,7 @@ import { datasetEntries } from "./routers/datasetEntries.router";
import { projectsRouter } from "./routers/projects.router";
import { dashboardRouter } from "./routers/dashboard.router";
import { loggedCallsRouter } from "./routers/loggedCalls.router";
import { usersRouter } from "./routers/users.router";
/**
* This is the primary router for your server.
@@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({
projects: projectsRouter,
dashboard: dashboardRouter,
loggedCalls: loggedCallsRouter,
users: usersRouter,
});
// export type definition of API

View File

@@ -51,6 +51,12 @@ export const projectsRouter = createTRPCRouter({
include: {
apiKeys: true,
personalProjectUser: true,
projectUsers: {
include: {
user: true,
},
},
projectUserInvitations: true,
},
}),
prisma.projectUser.findFirst({
@@ -58,7 +64,7 @@ export const projectsRouter = createTRPCRouter({
userId: ctx.session.user.id,
projectId: input.id,
role: {
in: ["ADMIN", "MEMBER"],
in: ["ADMIN", "MEMBER", "VIEWER"],
},
},
}),

View File

@@ -0,0 +1,232 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db";
import { error, success } from "~/utils/errorHandling/standardResponses";
import { requireIsProjectAdmin, requireNothing } from "~/utils/accessControl";
import { TRPCError } from "@trpc/server";
import { sendProjectInvitation } from "~/server/emails/sendProjectInvitation";
export const usersRouter = createTRPCRouter({
inviteToProject: protectedProcedure
.input(
z.object({
projectId: z.string(),
email: z.string().email(),
role: z.enum(["ADMIN", "MEMBER", "VIEWER"]),
}),
)
.mutation(async ({ input, ctx }) => {
await requireIsProjectAdmin(input.projectId, ctx);
const user = await prisma.user.findUnique({
where: {
email: input.email,
},
});
if (user) {
const existingMembership = await prisma.projectUser.findUnique({
where: {
projectId_userId: {
projectId: input.projectId,
userId: user.id,
},
},
});
if (existingMembership) {
return error(`A user with ${input.email} is already a member of this project`);
}
}
const invitation = await prisma.userInvitation.upsert({
where: {
projectId_email: {
projectId: input.projectId,
email: input.email,
},
},
update: {
role: input.role,
},
create: {
projectId: input.projectId,
email: input.email,
role: input.role,
invitationToken: uuidv4(),
senderId: ctx.session.user.id,
},
include: {
project: {
select: {
name: true,
},
},
},
});
try {
await sendProjectInvitation({
invitationToken: invitation.invitationToken,
recipientEmail: input.email,
invitationSenderName: ctx.session.user.name || "",
invitationSenderEmail: ctx.session.user.email || "",
projectName: invitation.project.name,
});
} catch (e) {
// If we fail to send the email, we should delete the invitation
await prisma.userInvitation.delete({
where: {
invitationToken: invitation.invitationToken,
},
});
return error("Failed to send email");
}
return success();
}),
getProjectInvitation: protectedProcedure
.input(
z.object({
invitationToken: z.string(),
}),
)
.query(async ({ input, ctx }) => {
requireNothing(ctx);
const invitation = await prisma.userInvitation.findUnique({
where: {
invitationToken: input.invitationToken,
},
include: {
project: {
select: {
name: true,
},
},
sender: {
select: {
name: true,
email: true,
},
},
},
});
if (!invitation) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return invitation;
}),
acceptProjectInvitation: protectedProcedure
.input(
z.object({
invitationToken: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
requireNothing(ctx);
const invitation = await prisma.userInvitation.findUnique({
where: {
invitationToken: input.invitationToken,
},
});
if (!invitation) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await prisma.projectUser.create({
data: {
projectId: invitation.projectId,
userId: ctx.session.user.id,
role: invitation.role,
},
});
await prisma.userInvitation.delete({
where: {
invitationToken: input.invitationToken,
},
});
return success(invitation.projectId);
}),
cancelProjectInvitation: protectedProcedure
.input(
z.object({
invitationToken: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
requireNothing(ctx);
const invitation = await prisma.userInvitation.findUnique({
where: {
invitationToken: input.invitationToken,
},
});
if (!invitation) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await prisma.userInvitation.delete({
where: {
invitationToken: input.invitationToken,
},
});
return success();
}),
editProjectUserRole: protectedProcedure
.input(
z.object({
projectId: z.string(),
userId: z.string(),
role: z.enum(["ADMIN", "MEMBER", "VIEWER"]),
}),
)
.mutation(async ({ input, ctx }) => {
await requireIsProjectAdmin(input.projectId, ctx);
await prisma.projectUser.update({
where: {
projectId_userId: {
projectId: input.projectId,
userId: input.userId,
},
},
data: {
role: input.role,
},
});
return success();
}),
removeUserFromProject: protectedProcedure
.input(
z.object({
projectId: z.string(),
userId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await requireIsProjectAdmin(input.projectId, ctx);
await prisma.projectUser.delete({
where: {
projectId_userId: {
projectId: input.projectId,
userId: input.userId,
},
},
});
return success();
}),
});

View File

@@ -0,0 +1,31 @@
import { marked } from "marked";
import nodemailer from "nodemailer";
import { env } from "~/env.mjs";
const transporter = nodemailer.createTransport({
// All the SMTP_ env vars come from https://app.brevo.com/settings/keys/smtp
// @ts-expect-error nodemailer types are wrong
host: env.SMTP_HOST,
port: env.SMTP_PORT,
auth: {
user: env.SMTP_LOGIN,
pass: env.SMTP_PASSWORD,
},
});
export const sendEmail = async (options: { to: string; subject: string; body: string }) => {
const bodyHtml = await marked.parseInline(options.body, { mangle: false });
try {
await transporter.sendMail({
from: env.SENDER_EMAIL,
to: options.to,
subject: options.subject,
html: bodyHtml,
text: options.body,
});
} catch (error) {
console.error("error sending email", error);
throw error;
}
};

View File

@@ -0,0 +1,29 @@
import { env } from "~/env.mjs";
import { sendEmail } from "./sendEmail";
export const sendProjectInvitation = async ({
invitationToken,
recipientEmail,
invitationSenderName,
invitationSenderEmail,
projectName,
}: {
invitationToken: string;
recipientEmail: string;
invitationSenderName: string;
invitationSenderEmail: string;
projectName: string;
}) => {
const invitationLink = `${env.NEXT_PUBLIC_HOST}/invitations/${invitationToken}`;
const emailBody = `
<p>You have been invited to join ${projectName} by ${invitationSenderName} (${invitationSenderEmail}).</p>
<p>Click <a href="${invitationLink}">here</a> to accept the invitation.</p>
`;
await sendEmail({
to: recipientEmail,
subject: "You've been invited to join a project",
body: emailBody,
});
};

View File

@@ -18,7 +18,7 @@ const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpe
const modalTheme = defineMultiStyleConfig({
baseStyle: definePartsStyle({
dialog: { borderRadius: "sm" },
dialog: { borderRadius: "md" },
}),
});