chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
|
||||
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
|
||||
import { createEnvironment } from "./organization.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { ssoController } from "~/services/sso.server";
|
||||
|
||||
export const INVITE_NOT_FOUND = "Invite not found";
|
||||
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
|
||||
"Membership for this organization is managed by Directory Sync, so invites can't be accepted.";
|
||||
export const ENV_SETUP_INCOMPLETE =
|
||||
"You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists.";
|
||||
|
||||
export function isAcceptInviteFormError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === INVITE_NOT_FOUND ||
|
||||
error.message === ENV_SETUP_INCOMPLETE ||
|
||||
error.message === INVITE_BLOCKED_DIRECTORY_MANAGED)
|
||||
);
|
||||
}
|
||||
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getProjectsMissingMemberDevelopmentEnvironments({
|
||||
memberId,
|
||||
organizationId,
|
||||
projects,
|
||||
}: {
|
||||
memberId: string;
|
||||
organizationId: string;
|
||||
projects: Pick<Project, "id">[];
|
||||
}) {
|
||||
if (projects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingEnvs = await prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
orgMemberId: memberId,
|
||||
organizationId,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: { in: projects.map((project) => project.id) },
|
||||
},
|
||||
select: { projectId: true },
|
||||
});
|
||||
const existingProjectIds = new Set(existingEnvs.map((env) => env.projectId));
|
||||
|
||||
return projects.filter((project) => !existingProjectIds.has(project.id));
|
||||
}
|
||||
|
||||
export async function provisionMemberDevelopmentEnvironments({
|
||||
inviteId,
|
||||
user,
|
||||
member,
|
||||
organization,
|
||||
projects,
|
||||
maximumConcurrencyLimit,
|
||||
}: {
|
||||
inviteId: string;
|
||||
user: { id: string; email: string };
|
||||
member: OrgMember;
|
||||
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
|
||||
projects: Pick<Project, "id">[];
|
||||
maximumConcurrencyLimit: number;
|
||||
}) {
|
||||
const projectsNeedingEnvs = await getProjectsMissingMemberDevelopmentEnvironments({
|
||||
memberId: member.id,
|
||||
organizationId: organization.id,
|
||||
projects,
|
||||
});
|
||||
const projectIds = projects.map((project) => project.id);
|
||||
const createdProjectIds: string[] = [];
|
||||
let failedProjectId: string | undefined;
|
||||
let failedProjectIndex: number | undefined;
|
||||
|
||||
try {
|
||||
for (const [index, project] of projectsNeedingEnvs.entries()) {
|
||||
failedProjectId = project.id;
|
||||
failedProjectIndex = index;
|
||||
|
||||
await createEnvironment({
|
||||
organization,
|
||||
project,
|
||||
type: "DEVELOPMENT",
|
||||
// We set this true but no backfill (yet!?) so never used
|
||||
// for dev environments
|
||||
isBranchableEnvironment: true,
|
||||
member,
|
||||
maximumConcurrencyLimit,
|
||||
});
|
||||
|
||||
createdProjectIds.push(project.id);
|
||||
failedProjectId = undefined;
|
||||
failedProjectIndex = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("acceptInvite: development environment creation failed after membership created", {
|
||||
inviteId,
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
orgMemberId: member.id,
|
||||
projectIds,
|
||||
failedProjectId,
|
||||
failedProjectIndex,
|
||||
totalProjects: projectsNeedingEnvs.length,
|
||||
createdProjectIds,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
|
||||
throw new Error(ENV_SETUP_INCOMPLETE);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignInviteRbacRole({
|
||||
userId,
|
||||
organizationId,
|
||||
rbacRoleId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
rbacRoleId: string;
|
||||
}) {
|
||||
try {
|
||||
const roleResult = await rbac.setUserRole({
|
||||
userId,
|
||||
organizationId,
|
||||
roleId: rbacRoleId,
|
||||
});
|
||||
if (!roleResult.ok) {
|
||||
logger.error("acceptInvite: skipped RBAC role assignment", {
|
||||
organizationId,
|
||||
userId,
|
||||
rbacRoleId,
|
||||
reason: roleResult.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("acceptInvite: RBAC role assignment threw", {
|
||||
organizationId,
|
||||
userId,
|
||||
rbacRoleId,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRecoverIncompleteInviteAccept({
|
||||
user,
|
||||
organizationId,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
organizationId: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const member = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
organization: { deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
projects: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const missingProjects = await getProjectsMissingMemberDevelopmentEnvironments({
|
||||
memberId: member.id,
|
||||
organizationId,
|
||||
projects: member.organization.projects,
|
||||
});
|
||||
|
||||
if (missingProjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
|
||||
organizationId,
|
||||
"DEVELOPMENT"
|
||||
);
|
||||
|
||||
await provisionMemberDevelopmentEnvironments({
|
||||
inviteId,
|
||||
user,
|
||||
member,
|
||||
organization: member.organization,
|
||||
projects: missingProjects,
|
||||
maximumConcurrencyLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
remainingInvites: await getUsersInvites({ email: user.email }),
|
||||
organization: member.organization,
|
||||
};
|
||||
}
|
||||
|
||||
export async function acceptInvite({
|
||||
user,
|
||||
inviteId,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const invite = await prisma.orgMemberInvite.findFirst({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
projects: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
if (organizationId) {
|
||||
const recovered = await tryRecoverIncompleteInviteAccept({
|
||||
user,
|
||||
organizationId,
|
||||
inviteId,
|
||||
});
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
throw new Error(INVITE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Directory-managed membership: accepting an invite would add a member
|
||||
// outside the directory. Block it (the invite can still be revoked by an
|
||||
// admin). Fail-open on a plugin error so a hiccup doesn't strand joiners.
|
||||
const membershipPolicy = await ssoController.getMembershipPolicy(invite.organizationId);
|
||||
if (membershipPolicy.isOk() && !membershipPolicy.value.manualMembershipAllowed) {
|
||||
throw new Error(INVITE_BLOCKED_DIRECTORY_MANAGED);
|
||||
}
|
||||
|
||||
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
|
||||
invite.organizationId,
|
||||
"DEVELOPMENT"
|
||||
);
|
||||
|
||||
let member = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
organization: { deletedAt: null },
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
try {
|
||||
member = await prisma.orgMember.create({
|
||||
data: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
member = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
organization: { deletedAt: null },
|
||||
},
|
||||
});
|
||||
if (!member) {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await provisionMemberDevelopmentEnvironments({
|
||||
inviteId,
|
||||
user,
|
||||
member,
|
||||
organization: invite.organization,
|
||||
projects: invite.organization.projects,
|
||||
maximumConcurrencyLimit,
|
||||
});
|
||||
|
||||
// Consume the invite only after development environments are provisioned so
|
||||
// a failed setup can be retried from /invites.
|
||||
try {
|
||||
await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingInvites = await getUsersInvites({ email: user.email });
|
||||
|
||||
// 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 (invite.rbacRoleId) {
|
||||
await assignInviteRbacRole({
|
||||
userId: user.id,
|
||||
organizationId: invite.organization.id,
|
||||
rbacRoleId: invite.rbacRoleId,
|
||||
});
|
||||
}
|
||||
|
||||
// Deliberate re-admission clears any sticky-removal tombstone so this
|
||||
// membership isn't shadowed by a prior removal (best-effort; no-op in OSS).
|
||||
await ssoController
|
||||
.clearMembershipRemoval({ organizationId: invite.organization.id, userId: user.id })
|
||||
.unwrapOr(undefined);
|
||||
|
||||
return { remainingInvites, organization: invite.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 tx.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
//2. check for other invites
|
||||
const remainingInvites = await tx.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 };
|
||||
}
|
||||
Reference in New Issue
Block a user