Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
import { sendEmailFromTemplate } from "@/lib/emails";
import { getItemQuantityForCustomer } from "@/lib/payments";
import { grantTeamPermission } from "@/lib/permissions";
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, permissionDefinitionIdSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { teamsCrudHandlers } from "../../teams/crud";

export const teamInvitationCodeHandler = createVerificationCodeHandler({
Expand All @@ -30,6 +31,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
type: VerificationCodeType.TEAM_INVITATION,
data: yupObject({
team_id: yupString().defined(),
permission_ids: yupArray(permissionDefinitionIdSchema.defined()).optional(),
}).defined(),
method: yupObject({
email: emailSchema.defined(),
Expand Down Expand Up @@ -106,11 +108,26 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
});

if (!oldMembership) {
await teamMembershipsCrudHandlers.adminCreate({
tenancy,
team_id: data.team_id,
user_id: user.id,
data: {},
await retryTransaction(prisma, async (tx) => {
await teamMembershipsCrudHandlers.adminCreate({
tenancy,
team_id: data.team_id,
user_id: user.id,
data: {},
});

// Apply additional specific permissions if provided (with deduplication)
if (data.permission_ids && data.permission_ids.length > 0) {
const uniquePermissionIds = [...new Set(data.permission_ids)];
for (const permissionId of uniquePermissionIds) {
await grantTeamPermission(tx, {
tenancy,
teamId: data.team_id,
userId: user.id,
permissionId,
});
}
}
});
Comment thread
bootssecurity marked this conversation as resolved.
}

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/app/api/latest/team-invitations/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
team_id: code.data.team_id,
expires_at_millis: code.expiresAt.getTime(),
recipient_email: code.method.email,
permission_ids: code.data.permission_ids || [],
})),
is_paginated: false,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { listPermissionDefinitions } from "@/lib/permissions";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const GET = createSmartRouteHandler({
metadata: {
summary: "Get role-based permissions for team invitations",
description: "Fetch available role-based permissions that can be assigned to team members during invitations. Only returns role-based permissions, not system permissions.",
Comment thread
bootssecurity marked this conversation as resolved.
tags: ["Teams"],
},
Comment thread
bootssecurity marked this conversation as resolved.
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema,
tenancy: adaptSchema.defined(),
user: adaptSchema.optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupArray(yupObject({
id: yupString().defined(),
description: yupString().optional(),
contained_permission_ids: yupArray(yupString().defined()).defined(),
}).defined()).defined(),
is_paginated: yupBoolean().oneOf([false]).defined(),
}).defined(),
}),
async handler({ auth }) {
const allPermissions = await listPermissionDefinitions({
scope: "team",
tenancy: auth.tenancy,
});

// Return all permissions including system permissions (starting with $)
return {
statusCode: 200,
bodyType: "json",
body: {
items: allPermissions,
is_paginated: false,
},
};
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, permissionDefinitionIdSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { teamInvitationCodeHandler } from "../accept/verification-code-handler";

export const POST = createSmartRouteHandler({
Expand All @@ -21,6 +21,7 @@ export const POST = createSmartRouteHandler({
team_id: teamIdSchema.defined(),
email: teamInvitationEmailSchema.defined(),
callback_url: teamInvitationCallbackUrlSchema.defined(),
permission_ids: yupArray(permissionDefinitionIdSchema.defined()).optional(),
}).defined(),
}),
response: yupObject({
Expand Down Expand Up @@ -52,6 +53,7 @@ export const POST = createSmartRouteHandler({
tenancy: auth.tenancy,
data: {
team_id: body.team_id,
permission_ids: body.permission_ids || [],
},
method: {
email: body.email,
Expand Down
65 changes: 61 additions & 4 deletions apps/backend/src/app/api/latest/team-member-profiles/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,36 @@ import { getUserLastActiveAtMillis, getUsersLastActiveAtMillis, userFullInclude,

const fullInclude = { projectUser: { include: userFullInclude } };

function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>, lastActiveAtMillis: number) {
// Helper function to fetch permissions for team members
async function fetchTeamMemberPermissions(tx: any, tenancyId: string, teamId: string, projectUserIds: string[]) {
const permissions = await tx.teamMemberDirectPermission.findMany({
where: {
tenancyId,
teamId,
projectUserId: { in: projectUserIds },
},
select: { projectUserId: true, permissionId: true },
});

// Group permissions by projectUserId
const permissionMap = new Map<string, string[]>();
for (const perm of permissions) {
if (!permissionMap.has(perm.projectUserId)) {
permissionMap.set(perm.projectUserId, []);
}
permissionMap.get(perm.projectUserId)!.push(perm.permissionId);
}

return permissionMap;
}
Comment thread
bootssecurity marked this conversation as resolved.

function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>, lastActiveAtMillis: number, permissionIds: string[]) {
return {
team_id: prisma.teamId,
user_id: prisma.projectUserId,
display_name: prisma.displayName ?? prisma.projectUser.displayName,
profile_image_url: prisma.profileImageUrl ?? prisma.projectUser.profileImageUrl,
permission_ids: permissionIds,
user: userPrismaToCrud(prisma.projectUser, lastActiveAtMillis),
};
}
Expand Down Expand Up @@ -78,10 +102,22 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
include: fullInclude,
});

// Fetch all permissions in a single query to avoid N+1 pattern
const permissionMap = await fetchTeamMemberPermissions(
tx,
auth.tenancy.id,
query.team_id!,
db.map(member => member.projectUserId)
);

Comment thread
bootssecurity marked this conversation as resolved.
const lastActiveAtMillis = await getUsersLastActiveAtMillis(auth.project.id, auth.branchId, db.map(user => user.projectUserId), db.map(user => user.createdAt));

return {
items: db.map((user, index) => prismaToCrud(user, lastActiveAtMillis[index])),
items: db.map((user, index) => prismaToCrud(
user,
lastActiveAtMillis[index],
permissionMap.get(user.projectUserId) || []
)),
is_paginated: false,
};
});
Expand Down Expand Up @@ -121,7 +157,19 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
throw new KnownErrors.TeamMembershipNotFound(params.team_id, params.user_id);
}

return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime());
// Use helper function to fetch permissions
const permissionMap = await fetchTeamMemberPermissions(
tx,
auth.tenancy.id,
db.teamId,
[db.projectUserId]
);

return prismaToCrud(
db,
await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUserId) ?? db.projectUser.createdAt.getTime(),
permissionMap.get(db.projectUserId) || []
);
});
},
onUpdate: async ({ auth, data, params }) => {
Expand Down Expand Up @@ -155,7 +203,16 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
include: fullInclude,
});

return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime());
const perms = await tx.teamMemberDirectPermission.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: db.projectUserId,
teamId: db.teamId,
},
select: { permissionId: true },
});

return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime(), perms.map(p => p.permissionId));
Comment thread
BilalG1 marked this conversation as resolved.
});
},
}));
Loading