4fde283e76
#3977 added formatting and linting everywhere else. This extends it to the webapp.
363 lines
8.2 KiB
TypeScript
363 lines
8.2 KiB
TypeScript
import { type Prisma, prisma } from "~/db.server";
|
|
import { createEnvironment } from "./organization.server";
|
|
import { customAlphabet } from "nanoid";
|
|
import { logger } from "~/services/logger.server";
|
|
import { rbac } from "~/services/rbac.server";
|
|
|
|
const tokenValueLength = 40;
|
|
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
|
|
|
export async function getTeamMembersAndInvites({
|
|
userId,
|
|
organizationId,
|
|
}: {
|
|
userId: string;
|
|
organizationId: string;
|
|
}) {
|
|
const org = await prisma.organization.findFirst({
|
|
where: { id: organizationId, members: { some: { userId } } },
|
|
select: {
|
|
members: {
|
|
select: {
|
|
id: true,
|
|
role: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
avatarUrl: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
invites: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
updatedAt: true,
|
|
inviter: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
avatarUrl: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!org) {
|
|
return null;
|
|
}
|
|
|
|
return { members: org.members, invites: org.invites };
|
|
}
|
|
|
|
export async function removeTeamMember({
|
|
userId,
|
|
slug,
|
|
memberId,
|
|
}: {
|
|
userId: string;
|
|
slug: string;
|
|
memberId: string;
|
|
}) {
|
|
const org = await prisma.organization.findFirst({
|
|
where: { slug, members: { some: { userId } } },
|
|
});
|
|
|
|
if (!org) {
|
|
throw new Error("User does not have access to this organization");
|
|
}
|
|
|
|
// Scope the target to this org. A member id is a globally unique key, so
|
|
// deleting by id alone would remove members of other orgs; bind it to the
|
|
// resolved org and reject a foreign id.
|
|
const member = await prisma.orgMember.findFirst({
|
|
where: { id: memberId, organizationId: org.id },
|
|
include: {
|
|
organization: true,
|
|
user: true,
|
|
},
|
|
});
|
|
|
|
if (!member) {
|
|
throw new Error("Member not found in this organization");
|
|
}
|
|
|
|
await prisma.orgMember.delete({ where: { id: member.id } });
|
|
|
|
return member;
|
|
}
|
|
|
|
export async function inviteMembers({
|
|
slug,
|
|
emails,
|
|
userId,
|
|
rbacRoleId,
|
|
}: {
|
|
slug: string;
|
|
emails: string[];
|
|
userId: string;
|
|
/**
|
|
* Optional RBAC role to attach to the invite. When set, accepted
|
|
* invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember
|
|
* is created.
|
|
*
|
|
* `OrgMemberInvite.role` is still set if the plugin isn't installed.
|
|
*/
|
|
rbacRoleId?: string | null;
|
|
}) {
|
|
const org = await prisma.organization.findFirst({
|
|
where: { slug, members: { some: { userId } } },
|
|
});
|
|
|
|
if (!org) {
|
|
throw new Error("User does not have access to this organization");
|
|
}
|
|
|
|
const invites = [...new Set(emails)].map(
|
|
(email) =>
|
|
({
|
|
email,
|
|
token: tokenGenerator(),
|
|
organizationId: org.id,
|
|
inviterId: userId,
|
|
role: "MEMBER",
|
|
rbacRoleId: rbacRoleId ?? null,
|
|
}) satisfies Prisma.OrgMemberInviteCreateManyInput
|
|
);
|
|
|
|
await prisma.orgMemberInvite.createMany({
|
|
data: invites,
|
|
});
|
|
|
|
return await prisma.orgMemberInvite.findMany({
|
|
where: {
|
|
organizationId: org.id,
|
|
inviterId: userId,
|
|
email: {
|
|
in: emails,
|
|
},
|
|
},
|
|
include: {
|
|
organization: true,
|
|
inviter: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function getInviteFromToken({ token }: { token: string }) {
|
|
return await prisma.orgMemberInvite.findFirst({
|
|
where: {
|
|
token,
|
|
},
|
|
include: {
|
|
organization: true,
|
|
inviter: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function getUsersInvites({ email }: { email: string }) {
|
|
return await prisma.orgMemberInvite.findMany({
|
|
where: {
|
|
email,
|
|
organization: {
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
organization: true,
|
|
inviter: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function acceptInvite({
|
|
user,
|
|
inviteId,
|
|
}: {
|
|
user: { id: string; email: string };
|
|
inviteId: string;
|
|
}) {
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
// 1. Delete the invite and get the invite details
|
|
const invite = await tx.orgMemberInvite.delete({
|
|
where: {
|
|
id: inviteId,
|
|
email: user.email,
|
|
},
|
|
include: {
|
|
organization: {
|
|
include: {
|
|
projects: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// 2. Join the organization
|
|
const member = await tx.orgMember.create({
|
|
data: {
|
|
organizationId: invite.organizationId,
|
|
userId: user.id,
|
|
role: invite.role,
|
|
},
|
|
});
|
|
|
|
// 3. Create an environment for each project
|
|
for (const project of invite.organization.projects) {
|
|
await createEnvironment({
|
|
organization: invite.organization,
|
|
project,
|
|
type: "DEVELOPMENT",
|
|
// We set this true but no backfill (yet!?) so never used
|
|
// for dev environments
|
|
isBranchableEnvironment: true,
|
|
member,
|
|
prismaClient: tx,
|
|
});
|
|
}
|
|
|
|
// 4. Check for other invites
|
|
const remainingInvites = await tx.orgMemberInvite.findMany({
|
|
where: {
|
|
email: user.email,
|
|
},
|
|
});
|
|
|
|
return {
|
|
remainingInvites,
|
|
organization: invite.organization,
|
|
inviteRole: invite.role,
|
|
rbacRoleId: invite.rbacRoleId,
|
|
};
|
|
});
|
|
|
|
// If the invite carried an explicit RBAC role, assign it. Best-effort: the
|
|
// invite is already consumed and membership created above, so a failure here
|
|
// — a returned {ok:false} or a thrown error from the plugin — must not block
|
|
// joining the org. Swallow and log either way; without the catch a plugin
|
|
// throw escapes and turns the whole invite-accept into a 400.
|
|
if (result.rbacRoleId) {
|
|
try {
|
|
const roleResult = await rbac.setUserRole({
|
|
userId: user.id,
|
|
organizationId: result.organization.id,
|
|
roleId: result.rbacRoleId,
|
|
});
|
|
if (!roleResult.ok) {
|
|
logger.error("acceptInvite: skipped RBAC role assignment", {
|
|
organizationId: result.organization.id,
|
|
userId: user.id,
|
|
rbacRoleId: result.rbacRoleId,
|
|
reason: roleResult.error,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error("acceptInvite: RBAC role assignment threw", {
|
|
organizationId: result.organization.id,
|
|
userId: user.id,
|
|
rbacRoleId: result.rbacRoleId,
|
|
error:
|
|
error instanceof Error
|
|
? { name: error.name, message: error.message, stack: error.stack }
|
|
: String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { remainingInvites: result.remainingInvites, organization: result.organization };
|
|
}
|
|
|
|
export async function declineInvite({
|
|
user,
|
|
inviteId,
|
|
}: {
|
|
user: { id: string; email: string };
|
|
inviteId: string;
|
|
}) {
|
|
return await prisma.$transaction(async (tx) => {
|
|
//1. delete invite
|
|
const declinedInvite = await prisma.orgMemberInvite.delete({
|
|
where: {
|
|
id: inviteId,
|
|
email: user.email,
|
|
},
|
|
include: {
|
|
organization: true,
|
|
},
|
|
});
|
|
|
|
//2. check for other invites
|
|
const remainingInvites = await prisma.orgMemberInvite.findMany({
|
|
where: {
|
|
email: user.email,
|
|
},
|
|
});
|
|
|
|
return { remainingInvites, organization: declinedInvite.organization };
|
|
});
|
|
}
|
|
|
|
export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) {
|
|
return await prisma.orgMemberInvite.update({
|
|
where: {
|
|
id: inviteId,
|
|
inviterId: userId,
|
|
},
|
|
data: {
|
|
updatedAt: new Date(),
|
|
},
|
|
include: {
|
|
inviter: true,
|
|
organization: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function revokeInvite({
|
|
userId,
|
|
orgSlug,
|
|
inviteId,
|
|
}: {
|
|
userId: string;
|
|
orgSlug: string;
|
|
inviteId: string;
|
|
}) {
|
|
const invite = await prisma.orgMemberInvite.findFirst({
|
|
where: {
|
|
id: inviteId,
|
|
organization: {
|
|
slug: orgSlug,
|
|
members: {
|
|
some: {
|
|
userId,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
organization: true,
|
|
},
|
|
});
|
|
|
|
if (!invite) {
|
|
throw new Error("Invite not found");
|
|
}
|
|
|
|
await prisma.orgMemberInvite.delete({
|
|
where: {
|
|
id: invite.id,
|
|
},
|
|
});
|
|
|
|
return { email: invite.email, organization: invite.organization };
|
|
}
|