diff --git a/app/.env.example b/app/.env.example index ac74e20..33ba9ae 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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" diff --git a/app/@types/nextjs-routes.d.ts b/app/@types/nextjs-routes.d.ts index e95f9a1..d28168c 100644 --- a/app/@types/nextjs-routes.d.ts +++ b/app/@types/nextjs-routes.d.ts @@ -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"> diff --git a/app/package.json b/app/package.json index 2889351..40d7209 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/prisma/migrations/20230815234811_add_user_invitations/migration.sql b/app/prisma/migrations/20230815234811_add_user_invitations/migration.sql new file mode 100644 index 0000000..0a0bcad --- /dev/null +++ b/app/prisma/migrations/20230815234811_add_user_invitations/migration.sql @@ -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; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index cadbb2c..ed25d5e 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -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 diff --git a/app/src/components/projectSettings/InviteMemberModal.tsx b/app/src/components/projectSettings/InviteMemberModal.tsx new file mode 100644 index 0000000..164399f --- /dev/null +++ b/app/src/components/projectSettings/InviteMemberModal.tsx @@ -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("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 ( + + + + + + Invite Member + + + + + + + Invite a new member to {selectedProject?.name}. + + + setRole(e as ProjectUserRole)} + colorScheme="orange" + > + + + MEMBER + + + ADMIN + + + + + Email + setEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey || e.shiftKey)) { + e.preventDefault(); + e.currentTarget.blur(); + inviteMember(); + } + }} + /> + Enter the email of the person you want to invite. + + + + + + + + + + + + ); +}; diff --git a/app/src/components/projectSettings/MemberTable.tsx b/app/src/components/projectSettings/MemberTable.tsx new file mode 100644 index 0000000..957d8c9 --- /dev/null +++ b/app/src/components/projectSettings/MemberTable.tsx @@ -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(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 ( + <> + + + + + + + {selectedProject?.role === "ADMIN" && + + + {selectedProject?.projectUsers.map((member) => { + return ( + + + + + {selectedProject.role === "ADMIN" && ( + + )} + + ); + })} + {selectedProject?.projectUserInvitations?.map((invitation) => { + return ( + + + + + {selectedProject.role === "ADMIN" && ( + + )} + + ); + })} + +
NameEmailRole} +
+ {member.user.name} + {member.user.email}{member.role} + {member.user.id !== session?.user?.id && + member.user.id !== selectedProject.personalProjectUserId && ( + } + onClick={() => setMemberToRemove(member.user)} + /> + )} +
+ Invitation pending + {invitation.email}{invitation.role} + +
+ + setMemberToRemove(null)} + /> + + ); +}; + +export default MemberTable; diff --git a/app/src/components/projectSettings/RemoveMemberDialog.tsx b/app/src/components/projectSettings/RemoveMemberDialog.tsx new file mode 100644 index 0000000..3828728 --- /dev/null +++ b/app/src/components/projectSettings/RemoveMemberDialog.tsx @@ -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(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 ( + + + + + Remove Member + + + + + + Are you sure you want to remove {member?.name} from the project? + + + + + + + + + + + + ); +}; diff --git a/app/src/env.mjs b/app/src/env.mjs index 6990709..3624c47 100644 --- a/app/src/env.mjs +++ b/app/src/env.mjs @@ -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. diff --git a/app/src/pages/experiments/[id].tsx b/app/src/pages/experiments/[id].tsx index 8c79866..9828eb1 100644 --- a/app/src/pages/experiments/[id].tsx +++ b/app/src/pages/experiments/[id].tsx @@ -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(); diff --git a/app/src/pages/invitations/[invitationToken].tsx b/app/src/pages/invitations/[invitationToken].tsx new file mode 100644 index 0000000..227d41e --- /dev/null +++ b/app/src/pages/invitations/[invitationToken].tsx @@ -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 ( + +
+ Loading... +
+
+ ); + } + + if (!invitationToken || !invitation.data) { + return ( + +
+ + The invitation you've received is invalid or expired. Please ask your project admin for + a new token. + +
+
+ ); + } + + return ( + <> + +
+ + + + You're invited! 🎉 + + + You've been invited to join {invitation.data.project.name} by{" "} + + {invitation.data.sender.name} ({invitation.data.sender.email}) + + . + + + + + + + +
+
+ + ); +} diff --git a/app/src/pages/project/settings/index.tsx b/app/src/pages/project/settings/index.tsx index 70f6824..e509cf5 100644 --- a/app/src/pages/project/settings/index.tsx +++ b/app/src/pages/project/settings/index.tsx @@ -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 ( <> - + @@ -109,6 +114,37 @@ export default function Settings() { + + Project Members + + + Add members to your project to allow them to view and edit your project's data. + + + + + + + + + Project API Key @@ -141,7 +177,7 @@ export default function Settings() { borderRadius={4} mt={2} height="auto" - onClick={deleteProjectOpen.onOpen} + onClick={deleteProjectDialog.onOpen} > @@ -153,7 +189,11 @@ export default function Settings() { - + + ); } diff --git a/app/src/server/api/root.router.ts b/app/src/server/api/root.router.ts index 4c4a14f..e3ed832 100644 --- a/app/src/server/api/root.router.ts +++ b/app/src/server/api/root.router.ts @@ -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 diff --git a/app/src/server/api/routers/projects.router.ts b/app/src/server/api/routers/projects.router.ts index abdffd9..2fbd8ca 100644 --- a/app/src/server/api/routers/projects.router.ts +++ b/app/src/server/api/routers/projects.router.ts @@ -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"], }, }, }), diff --git a/app/src/server/api/routers/users.router.ts b/app/src/server/api/routers/users.router.ts new file mode 100644 index 0000000..a29d307 --- /dev/null +++ b/app/src/server/api/routers/users.router.ts @@ -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(); + }), +}); diff --git a/app/src/server/emails/sendEmail.ts b/app/src/server/emails/sendEmail.ts new file mode 100644 index 0000000..2db6c8e --- /dev/null +++ b/app/src/server/emails/sendEmail.ts @@ -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; + } +}; diff --git a/app/src/server/emails/sendProjectInvitation.ts b/app/src/server/emails/sendProjectInvitation.ts new file mode 100644 index 0000000..3bb6419 --- /dev/null +++ b/app/src/server/emails/sendProjectInvitation.ts @@ -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 = ` +

You have been invited to join ${projectName} by ${invitationSenderName} (${invitationSenderEmail}).

+

Click here to accept the invitation.

+ `; + + await sendEmail({ + to: recipientEmail, + subject: "You've been invited to join a project", + body: emailBody, + }); +}; diff --git a/app/src/theme/ChakraThemeProvider.tsx b/app/src/theme/ChakraThemeProvider.tsx index 4db172a..5626da5 100644 --- a/app/src/theme/ChakraThemeProvider.tsx +++ b/app/src/theme/ChakraThemeProvider.tsx @@ -18,7 +18,7 @@ const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpe const modalTheme = defineMultiStyleConfig({ baseStyle: definePartsStyle({ - dialog: { borderRadius: "sm" }, + dialog: { borderRadius: "md" }, }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6d5d60..1852d48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@prisma/client': specifier: ^4.14.0 version: 4.14.0(prisma@4.14.0) + '@sendinblue/client': + specifier: ^3.3.1 + version: 3.3.1 '@sentry/nextjs': specifier: ^7.61.0 version: 7.61.0(next@13.4.2)(react@18.2.0)(webpack@5.88.2) @@ -137,12 +140,15 @@ importers: lucide-react: specifier: ^0.265.0 version: 0.265.0(react@18.2.0) + marked: + specifier: ^7.0.3 + version: 7.0.3 next: specifier: ^13.4.2 version: 13.4.2(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0) next-auth: specifier: ^4.22.1 - version: 4.22.1(next@13.4.2)(react-dom@18.2.0)(react@18.2.0) + version: 4.22.1(next@13.4.2)(nodemailer@6.9.4)(react-dom@18.2.0)(react@18.2.0) next-query-params: specifier: ^4.2.3 version: 4.2.3(next@13.4.2)(react@18.2.0)(use-query-params@2.2.1) @@ -152,6 +158,9 @@ importers: nextjs-routes: specifier: ^2.0.1 version: 2.0.1(next@13.4.2) + nodemailer: + specifier: ^6.9.4 + version: 6.9.4 openai: specifier: 4.0.0-beta.7 version: 4.0.0-beta.7 @@ -276,6 +285,9 @@ importers: '@types/node': specifier: ^18.16.0 version: 18.16.0 + '@types/nodemailer': + specifier: ^6.4.9 + version: 6.4.9 '@types/pg': specifier: ^8.10.2 version: 8.10.2 @@ -2439,7 +2451,7 @@ packages: next-auth: ^4 dependencies: '@prisma/client': 4.14.0(prisma@4.14.0) - next-auth: 4.22.1(next@13.4.2)(react-dom@18.2.0)(react@18.2.0) + next-auth: 4.22.1(next@13.4.2)(nodemailer@6.9.4)(react-dom@18.2.0)(react@18.2.0) dev: false /@next/env@13.4.2: @@ -2635,6 +2647,16 @@ packages: resolution: {integrity: sha512-0xd7qez0AQ+MbHatZTlI1gu5vkG8r7MYRUJAHPAHJBmGLs16zpkrpAVLvjQKQOqaXPDUBwOiJzNc00znHSCVBw==} dev: true + /@sendinblue/client@3.3.1: + resolution: {integrity: sha512-5xNGeT5gKD5XOvl5vHk682wvjJxRPnH3nc2vOZIaDX9XKuhoMaYXyEdqlP0R/Z6gEZiHhzpZxzrdiwlngGzsgw==} + dependencies: + '@types/bluebird': 3.5.38 + '@types/request': 2.48.8 + bluebird: 3.7.2 + lodash: 4.17.21 + request: 2.88.2 + dev: false + /@sentry-internal/tracing@7.61.0: resolution: {integrity: sha512-zTr+MXEG4SxNxif42LIgm2RQn+JRXL2NuGhRaKSD2i4lXKFqHVGlVdoWqY5UfqnnJPokiTWIj9ejR8I5HV8Ogw==} engines: {node: '>=8'} @@ -2957,13 +2979,21 @@ packages: resolution: {integrity: sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==} dev: false + /@types/bluebird@3.5.38: + resolution: {integrity: sha512-yR/Kxc0dd4FfwtEoLZMoqJbM/VE/W7hXn/MIjb+axcwag0iFmSPK7OBUZq1YWLynJUoWQkfUrI7T0HDqGApNSg==} + dev: false + /@types/body-parser@1.19.2: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 18.16.0 + '@types/node': 20.4.10 dev: true + /@types/caseless@0.12.2: + resolution: {integrity: sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==} + dev: false + /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: @@ -3066,7 +3096,7 @@ packages: /@types/express-serve-static-core@4.17.35: resolution: {integrity: sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==} dependencies: - '@types/node': 18.16.0 + '@types/node': 20.4.10 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 '@types/send': 0.17.1 @@ -3159,6 +3189,12 @@ packages: resolution: {integrity: sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg==} dev: true + /@types/nodemailer@6.4.9: + resolution: {integrity: sha512-XYG8Gv+sHjaOtUpiuytahMy2mM3rectgroNbs6R3djZEKmPNiIJwe9KqOJBGzKKnNZNKvnuvmugBgpq3w/S0ig==} + dependencies: + '@types/node': 20.4.10 + dev: true + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: false @@ -3218,6 +3254,15 @@ packages: '@types/scheduler': 0.16.3 csstype: 3.1.2 + /@types/request@2.48.8: + resolution: {integrity: sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ==} + dependencies: + '@types/caseless': 0.12.2 + '@types/node': 20.4.10 + '@types/tough-cookie': 4.0.2 + form-data: 2.5.1 + dev: false + /@types/scheduler@0.16.3: resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} @@ -3237,9 +3282,13 @@ packages: dependencies: '@types/http-errors': 2.0.1 '@types/mime': 3.0.1 - '@types/node': 18.16.0 + '@types/node': 20.4.10 dev: true + /@types/tough-cookie@4.0.2: + resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} + dev: false + /@types/unist@2.0.7: resolution: {integrity: sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==} dev: false @@ -3735,6 +3784,17 @@ packages: is-shared-array-buffer: 1.0.2 dev: true + /asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + dependencies: + safer-buffer: 2.1.2 + dev: false + + /assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + dev: false + /assert@2.0.0: resolution: {integrity: sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==} dependencies: @@ -3774,6 +3834,14 @@ packages: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} + /aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + dev: false + + /aws4@1.12.0: + resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} + dev: false + /axe-core@4.7.2: resolution: {integrity: sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==} engines: {node: '>=4'} @@ -3863,6 +3931,12 @@ packages: engines: {node: ^4.5.0 || >= 5.9} dev: false + /bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + dependencies: + tweetnacl: 0.14.5 + dev: false + /big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} dev: true @@ -3872,6 +3946,10 @@ packages: engines: {node: '>=8'} dev: false + /bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + dev: false + /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3973,6 +4051,10 @@ packages: /caniuse-lite@1.0.30001519: resolution: {integrity: sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==} + /caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + dev: false + /chai@4.3.7: resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} engines: {node: '>=4'} @@ -4224,6 +4306,10 @@ packages: toggle-selection: 1.0.6 dev: false + /core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + dev: false + /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: false @@ -4411,6 +4497,13 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true + /dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + dependencies: + assert-plus: 1.0.0 + dev: false + /data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -4590,6 +4683,13 @@ packages: readable-stream: 2.3.8 dev: false + /ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -5249,6 +5349,15 @@ packages: type: 2.7.2 dev: false + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: false + + /extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5377,10 +5486,32 @@ packages: dependencies: is-callable: 1.2.7 + /forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + dev: false + /form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} dev: false + /form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + + /form-data@2.5.1: + resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /form-data@3.0.1: resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} engines: {node: '>= 6'} @@ -5533,6 +5664,12 @@ packages: dependencies: resolve-pkg-maps: 1.0.0 + /getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + dependencies: + assert-plus: 1.0.0 + dev: false + /github-buttons@2.27.0: resolution: {integrity: sha512-PmfRMI2Rttg/2jDfKBeSl621sEznrsKF019SuoLdoNlO7qRUZaOyEI5Li4uW+79pVqnDtKfIEVuHTIJ5lgy64w==} dev: false @@ -5694,6 +5831,20 @@ packages: uglify-js: 3.17.4 dev: true + /har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + dev: false + + /har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + dependencies: + ajv: 6.12.6 + har-schema: 2.0.0 + dev: false + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -5782,6 +5933,15 @@ packages: toidentifier: 1.0.1 dev: false + /http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.17.0 + dev: false + /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -6052,6 +6212,10 @@ packages: dependencies: which-typed-array: 1.1.11 + /is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + dev: false + /is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: @@ -6084,6 +6248,10 @@ packages: requiresBuild: true dev: false + /isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + dev: false + /jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -6115,6 +6283,10 @@ packages: dependencies: argparse: 2.0.1 + /jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + dev: false + /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} @@ -6155,6 +6327,10 @@ packages: /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + dev: false + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true @@ -6163,6 +6339,10 @@ packages: resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} dev: false + /json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + dev: false + /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -6191,6 +6371,16 @@ packages: resolution: {integrity: sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==} dev: false + /jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -6358,6 +6548,12 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /marked@7.0.3: + resolution: {integrity: sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} dependencies: @@ -6525,7 +6721,7 @@ packages: /neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - /next-auth@4.22.1(next@13.4.2)(react-dom@18.2.0)(react@18.2.0): + /next-auth@4.22.1(next@13.4.2)(nodemailer@6.9.4)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-NTR3f6W7/AWXKw8GSsgSyQcDW6jkslZLH8AiZa5PQ09w1kR8uHtR9rez/E9gAq/o17+p0JYHE8QjF3RoniiObA==} peerDependencies: next: ^12.2.5 || ^13 @@ -6541,6 +6737,7 @@ packages: cookie: 0.5.0 jose: 4.14.4 next: 13.4.2(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0) + nodemailer: 6.9.4 oauth: 0.9.15 openid-client: 5.4.3 preact: 10.16.0 @@ -6676,11 +6873,20 @@ packages: /node-releases@2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} + /nodemailer@6.9.4: + resolution: {integrity: sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==} + engines: {node: '>=6.0.0'} + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} dev: false + /oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + dev: false + /oauth@0.9.15: resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} dev: false @@ -6972,6 +7178,10 @@ packages: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true + /performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + dev: false + /pg-cloudflare@1.1.1: resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} requiresBuild: true @@ -7249,6 +7459,10 @@ packages: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: false + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: false + /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} @@ -7267,6 +7481,11 @@ packages: side-channel: 1.0.4 dev: false + /qs@6.5.3: + resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} + engines: {node: '>=0.6'} + dev: false + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true @@ -7695,6 +7914,33 @@ packages: engines: {git: '>=2.11.0', node: '>=16.6.0', npm: '>=7.19.0', yarn: '>=1.7.0'} dev: false + /request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + dependencies: + aws-sign2: 0.7.0 + aws4: 1.12.0 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.3 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + dev: false + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -8005,6 +8251,22 @@ packages: engines: {node: '>= 10.x'} dev: false + /sshpk@1.17.0: + resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==} + engines: {node: '>=0.10.0'} + hasBin: true + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + dev: false + /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true @@ -8292,6 +8554,14 @@ packages: engines: {node: '>=0.6'} dev: false + /tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + dependencies: + psl: 1.9.0 + punycode: 2.3.0 + dev: false + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false @@ -8371,6 +8641,16 @@ packages: optionalDependencies: fsevents: 2.3.2 + /tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8636,6 +8916,12 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + dev: false + /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -8651,6 +8937,15 @@ packages: engines: {node: '>= 0.8'} dev: false + /verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + dev: false + /victory-vendor@36.6.11: resolution: {integrity: sha512-nT8kCiJp8dQh8g991J/R5w5eE2KnO8EAIP0xocWlh9l2okngMWglOPoMZzJvek8Q1KUc4XE/mJxTZnvOB1sTYg==} dependencies: