* 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
233 lines
5.6 KiB
TypeScript
233 lines
5.6 KiB
TypeScript
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();
|
|
}),
|
|
});
|