Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e976df96 | ||
|
|
b44efddc0f | ||
|
|
bc665cbeab | ||
|
|
02b1393ff4 | ||
|
|
52e27bab53 | ||
|
|
45278a2195 | ||
|
|
2e8b89c4ee | ||
|
|
7d17b443cf | ||
|
|
c6ab6b3d13 | ||
|
|
c741d8133e | ||
|
|
b01cb27c56 | ||
|
|
c48f2043f2 | ||
|
|
630eee4fab | ||
|
|
1ebf553181 | ||
|
|
e8a315ac4e | ||
|
|
c196ff1095 | ||
|
|
bf0fbe4888 | ||
|
|
eb6418c0d5 | ||
|
|
2df2868b20 | ||
|
|
7f48c7fdad | ||
|
|
2101051623 | ||
|
|
a241cb0bfc | ||
|
|
5417012cec | ||
|
|
8e818e0567 | ||
|
|
1fdd04215d | ||
|
|
0bf0749b29 | ||
|
|
4006143e3a | ||
|
|
c6ad767565 | ||
|
|
896f8dbd3d | ||
|
|
f1db27c0ad | ||
|
|
3a1e04bb42 | ||
|
|
ab37847ea1 | ||
|
|
0a2ddf05ca | ||
|
|
04fc0f1d52 | ||
|
|
b76c4c9dcc | ||
|
|
68b71bfb0a | ||
|
|
763c568797 | ||
|
|
4659d08c07 | ||
|
|
b9c350cc45 | ||
|
|
f63d705523 | ||
|
|
2907c5e696 | ||
|
|
dcb47e31ed | ||
|
|
cd5b75fe2c | ||
|
|
97bdf837a9 | ||
|
|
5663b2ae59 | ||
|
|
02b57eb82e | ||
|
|
b23dfa194e | ||
|
|
2484b6fad2 | ||
|
|
c154bb31b7 | ||
|
|
0cf4ebd544 | ||
|
|
805c4d4c09 | ||
|
|
99330b3117 | ||
|
|
cc90a62090 | ||
|
|
79bcd6dc30 | ||
|
|
c6daa61e5e | ||
|
|
b693b6898a | ||
|
|
fb55bc2dcd | ||
|
|
71bd140fe4 | ||
|
|
4c5608147c | ||
|
|
7d16da5c98 | ||
|
|
0ae6e92558 | ||
|
|
052a38e39b |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
Vendored
@@ -57,7 +57,7 @@ export const eventTypeSelect = {
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: { username: true, id: true },
|
||||
select: { username: true, id: true, timeZone: true },
|
||||
},
|
||||
children: {
|
||||
select: {
|
||||
|
||||
@@ -205,7 +205,8 @@ export class BookingsController_2024_04_15 {
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
try {
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId, body.rescheduleUid);
|
||||
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
@@ -459,7 +460,25 @@ export class BookingsController_2024_04_15 {
|
||||
return oAuthClientParams.platformClientId;
|
||||
}
|
||||
|
||||
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
|
||||
private async isValidRescheduleBooking(rescheduleUid: string, eventTypeId: number): Promise<boolean> {
|
||||
const { bookingInfo } = await getBookingInfo(rescheduleUid);
|
||||
if (!bookingInfo) {
|
||||
return false;
|
||||
}
|
||||
if (bookingInfo.status !== "ACCEPTED" && bookingInfo.status !== "PENDING") {
|
||||
return false;
|
||||
}
|
||||
if (bookingInfo.eventTypeId !== eventTypeId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async checkBookingRequiresAuthentication(
|
||||
req: Request,
|
||||
eventTypeId: number,
|
||||
rescheduleUid?: string
|
||||
): Promise<void> {
|
||||
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
||||
id: eventTypeId,
|
||||
});
|
||||
@@ -468,6 +487,17 @@ export class BookingsController_2024_04_15 {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rescheduleUid) {
|
||||
const isValidRescheduleBooking = await this.isValidRescheduleBooking(rescheduleUid, eventTypeId);
|
||||
if (isValidRescheduleBooking) {
|
||||
return;
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
"Trying to reschedule an event-type which requires authentication but provided invalid rescheduleUid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
|
||||
if (!userId) {
|
||||
@@ -478,8 +508,7 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
const isEventTypeOwner = eventType.userId === userId;
|
||||
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||
const isTeamAdminOrOwner =
|
||||
eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
const isTeamAdminOrOwner = eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
|
||||
let isOrgAdminOrOwner = false;
|
||||
if (eventType.team?.parentId) {
|
||||
|
||||
@@ -194,11 +194,6 @@ export class CreateBookingInput_2024_04_15 {
|
||||
@ApiHideProperty()
|
||||
skipContactOwner?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
_shouldServeCache?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
await this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationMembershipService as BaseOrganizationMembershipService } from "@calcom/platform-libraries/organizations";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationMembershipService extends BaseOrganizationMembershipService {
|
||||
constructor(organizationsRepository: OrganizationsRepository) {
|
||||
super({ organizationRepository: organizationsRepository });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
RolesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
export const Pbac = Reflector.createDecorator<PermissionString[]>();
|
||||
@@ -54,7 +54,7 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||
|
||||
if (org?.isOrganization && !org?.isPlatform) {
|
||||
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
|
||||
|
||||
@@ -55,7 +55,7 @@ export class IsOrgGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||
|
||||
if (org?.isOrganization) {
|
||||
canAccess = true;
|
||||
|
||||
@@ -43,7 +43,7 @@ export class IsWebhookInOrg implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||
|
||||
if (org?.isOrganization) {
|
||||
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import { PermissionCheckService, FeaturesRepository } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
export const REDIS_PBAC_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:has:pbac:guard:pbac`;
|
||||
export const REDIS_REQUIRED_PERMISSIONS_CACHE_KEY = (
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[]
|
||||
) =>
|
||||
`apiv2:user:${userId}:team:${teamId}:requiredPermissions:${requiredPermissions
|
||||
.sort()
|
||||
.join(",")}:guard:pbac`;
|
||||
|
||||
@Injectable()
|
||||
export class PbacGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prismaReadService: PrismaReadService,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const teamId = request.params.teamId;
|
||||
const orgId = request.params.orgId;
|
||||
const requiredPermissions = this.reflector.get(Pbac, context.getHandler());
|
||||
|
||||
const effectiveTeamId = teamId || orgId;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("PbacGuard - the request does not have an authorized user provided");
|
||||
}
|
||||
if (!effectiveTeamId) {
|
||||
throw new BadRequestException(
|
||||
"PbacGuard - can't check pbac because no teamId or orgId provided within the request url"
|
||||
);
|
||||
}
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
request.pbacAuthorizedRequest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasPbacEnabled = await this.hasPbacEnabled(Number(effectiveTeamId));
|
||||
if (!hasPbacEnabled) {
|
||||
request.pbacAuthorizedRequest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasRequiredPermissions = await this.checkUserHasRequiredPermissions(
|
||||
user.id,
|
||||
Number(effectiveTeamId),
|
||||
requiredPermissions
|
||||
);
|
||||
|
||||
if (!hasRequiredPermissions) {
|
||||
request.pbacAuthorizedRequest = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
request.pbacAuthorizedRequest = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async hasPbacEnabled(teamId: number) {
|
||||
const cachedHasPbacEnabled = await this.getCachePbacEnabled(teamId);
|
||||
|
||||
if (cachedHasPbacEnabled) {
|
||||
return cachedHasPbacEnabled;
|
||||
}
|
||||
|
||||
const pbacFeatureFlag = "pbac";
|
||||
const featuresRepository = new FeaturesRepository(this.prismaReadService.prisma);
|
||||
const hasPbacEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, pbacFeatureFlag);
|
||||
|
||||
if (hasPbacEnabled) {
|
||||
await this.setCachePbacEnabled(teamId, hasPbacEnabled);
|
||||
}
|
||||
|
||||
return hasPbacEnabled;
|
||||
}
|
||||
|
||||
async checkUserHasRequiredPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[]
|
||||
) {
|
||||
const cachedAccess = await this.getCacheRequiredPermissions(userId, teamId, requiredPermissions);
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasRequiredPermissions = await permissionCheckService.checkPermissions({
|
||||
userId,
|
||||
teamId,
|
||||
permissions: requiredPermissions,
|
||||
fallbackRoles: [],
|
||||
});
|
||||
|
||||
if (hasRequiredPermissions) {
|
||||
await this.setCacheRequiredPermissions(userId, teamId, requiredPermissions, hasRequiredPermissions);
|
||||
}
|
||||
|
||||
return hasRequiredPermissions;
|
||||
}
|
||||
|
||||
private async getCacheRequiredPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[]
|
||||
): Promise<boolean | null> {
|
||||
return this.redisService.get<boolean>(
|
||||
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions)
|
||||
);
|
||||
}
|
||||
|
||||
private async setCacheRequiredPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
requiredPermissions: PermissionString[],
|
||||
hasRequired: boolean
|
||||
): Promise<void> {
|
||||
await this.redisService.set<boolean>(
|
||||
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions),
|
||||
hasRequired,
|
||||
{ ttl: 300_000 }
|
||||
);
|
||||
}
|
||||
|
||||
private async getCachePbacEnabled(teamId: number) {
|
||||
const cachedResult = await this.redisService.get<boolean>(REDIS_PBAC_CACHE_KEY(teamId));
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) {
|
||||
await this.redisService.set<boolean>(REDIS_PBAC_CACHE_KEY(teamId), pbacEnabled, {
|
||||
ttl: 300_000,
|
||||
});
|
||||
}
|
||||
|
||||
throwForbiddenError(
|
||||
userId: number,
|
||||
teamId: string,
|
||||
orgId: string,
|
||||
requiredPermissions: PermissionString[]
|
||||
) {
|
||||
let errorMessage = `PbacGuard - user with id=${userId} does not have the minimum required permissions=${requiredPermissions.join(
|
||||
","
|
||||
)} `;
|
||||
if (teamId) {
|
||||
errorMessage += `within team with id=${teamId}`;
|
||||
}
|
||||
if (orgId) {
|
||||
errorMessage += `within organization with id=${orgId}`;
|
||||
}
|
||||
errorMessage += `.`;
|
||||
|
||||
throw new ForbiddenException(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger }
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
private readonly logger = new Logger("RolesGuard Logger");
|
||||
@@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
|
||||
|
||||
if (request.pbacAuthorizedRequest === true) {
|
||||
this.logger.debug("PBAC authorized request, skipping legacy role checking");
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamId = request.params.teamId as string;
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
|
||||
@@ -305,11 +305,24 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
new Date(invoice.period_end * 1000)
|
||||
);
|
||||
|
||||
const existingSubscription = await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const perActiveUserPrice = this.billingConfigService.get(PlatformPlan.PER_ACTIVE_USER)?.base;
|
||||
const subscriptionItem = existingSubscription.items.data.find(
|
||||
(item) => item.price?.id === perActiveUserPrice
|
||||
);
|
||||
|
||||
if (!subscriptionItem) {
|
||||
throw new NotFoundException(
|
||||
"No subscription item found for PER_ACTIVE_USER plan with matching price ID"
|
||||
);
|
||||
}
|
||||
|
||||
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
|
||||
},
|
||||
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,23 +4,17 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationRepository } from "@calcom/platform-libraries/organizations";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRepository {
|
||||
export class OrganizationsRepository extends OrganizationRepository {
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async findById(organizationId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
id: organizationId,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
) {
|
||||
super({ prismaClient: dbWrite.prisma });
|
||||
}
|
||||
|
||||
async findByIds(organizationIds: number[]) {
|
||||
|
||||
@@ -6,7 +6,7 @@ export class OrganizationsService {
|
||||
constructor(private readonly organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async isPlatform(organizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(organizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: organizationId });
|
||||
return organization?.isPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
|
||||
@@ -39,6 +40,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||
@@ -50,6 +52,7 @@ import { OrganizationsTeamsInviteController } from "@/modules/organizations/team
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRolesModule } from "@/modules/organizations/teams/roles/organizations-teams-roles.module";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
|
||||
@@ -96,6 +99,8 @@ import { Module } from "@nestjs/common";
|
||||
TeamsModule,
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsRolesModule,
|
||||
OrganizationsTeamsRolesModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
MembershipsModule,
|
||||
@@ -104,6 +109,7 @@ import { Module } from "@nestjs/common";
|
||||
],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
OrganizationMembershipService,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsService,
|
||||
OrganizationsTeamsService,
|
||||
@@ -196,4 +202,4 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesPrivateLinksController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
export class OrganizationsModule { }
|
||||
|
||||
+2
-2
@@ -103,12 +103,12 @@ export class ManagedOrganizationsService {
|
||||
}
|
||||
|
||||
private async isManagerOrganizationPlatform(managerOrganizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(managerOrganizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: managerOrganizationId });
|
||||
return !!organization?.isPlatform;
|
||||
}
|
||||
|
||||
async getManagedOrganization(managedOrganizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(managedOrganizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: managedOrganizationId });
|
||||
if (!organization) {
|
||||
throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { OrgPermissionStringValidator } from "../permissions/inputs/validators/org-permission-string.validator";
|
||||
|
||||
export const orgPermissionEnum = [...getAllPermissionStringsForScope(Scope.Organization)] as const;
|
||||
|
||||
export class BaseOrgRoleInput {
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "eventType.create", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(OrgPermissionStringValidator, { each: true })
|
||||
@IsOptional()
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
import { BaseOrgRoleInput } from "./base-org-role.input";
|
||||
|
||||
export class CreateOrgRoleInput extends BaseOrgRoleInput {
|
||||
@ApiProperty({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsString, IsOptional, MinLength } from "class-validator";
|
||||
|
||||
import { BaseOrgRoleInput } from "./base-org-role.input";
|
||||
|
||||
export class UpdateOrgRoleInput extends BaseOrgRoleInput {
|
||||
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
|
||||
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
|
||||
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
|
||||
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
|
||||
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
|
||||
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
|
||||
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
describe("Organizations Roles Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
// Test users
|
||||
let legacyOrgAdminUser: User;
|
||||
let legacyOrgMemberUser: User;
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithoutRolePermission: User;
|
||||
let nonOrgUser: User;
|
||||
|
||||
// API Keys
|
||||
let legacyOrgAdminApiKey: string;
|
||||
let legacyOrgMemberApiKey: string;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
let pbacOrgUserWithoutRolePermissionApiKey: string;
|
||||
let nonOrgUserApiKey: string;
|
||||
|
||||
// Organization
|
||||
let organization: Team;
|
||||
let pbacEnabledOrganization: Team;
|
||||
|
||||
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
|
||||
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
|
||||
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create test users
|
||||
legacyOrgAdminUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgAdminEmail,
|
||||
username: legacyOrgAdminEmail,
|
||||
});
|
||||
|
||||
legacyOrgMemberUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgMemberEmail,
|
||||
username: legacyOrgMemberEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithRolePermissionEmail,
|
||||
username: pbacOrgUserWithRolePermissionEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithoutRolePermissionEmail,
|
||||
username: pbacOrgUserWithoutRolePermissionEmail,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
});
|
||||
|
||||
// Create organizations
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: legacyOrgAdminUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: legacyOrgMemberUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const roleWithPermission = await roleService.createRole({
|
||||
name: "Role Manager",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update", "role.delete"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const roleWithoutPermission = await roleService.createRole({
|
||||
name: "Basic Role",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
|
||||
await roleService.assignRoleToMember(
|
||||
roleWithoutPermission.id,
|
||||
pbacOrgUserWithoutRolePermissionMembership.id
|
||||
);
|
||||
|
||||
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgAdminUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
|
||||
|
||||
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgMemberUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
pbacOrgUserWithRolePermission.id,
|
||||
null
|
||||
);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
|
||||
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
|
||||
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
nonOrgUser.id,
|
||||
null
|
||||
);
|
||||
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Role Creation Authorization", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Positive Tests", () => {
|
||||
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role PBAC",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
|
||||
const pbacSpyHasPbacEnabled = jest.spyOn(
|
||||
PbacGuard.prototype as unknown as PbacGuard,
|
||||
"hasPbacEnabled"
|
||||
);
|
||||
|
||||
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
|
||||
const rolesSpyCheckUserRoleAccess = jest.spyOn(
|
||||
RolesGuard.prototype as unknown as RolesGuard,
|
||||
"checkUserRoleAccess"
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
|
||||
expect(pbacSpyCanActivate).toHaveBeenCalled();
|
||||
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
|
||||
expect(rolesSpyCanActivate).toHaveBeenCalled();
|
||||
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
|
||||
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
|
||||
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
|
||||
expect(pbacCanActivateResult).toBe(true);
|
||||
expect(rolesCanActivateResult).toBe(true);
|
||||
expect(hasPbac).toBe(true);
|
||||
expect(rolesSpyCheckUserRoleAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role Legacy Admin",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
|
||||
const pbacSpyHasPbacEnabled = jest.spyOn(
|
||||
PbacGuard.prototype as unknown as PbacGuard,
|
||||
"hasPbacEnabled"
|
||||
);
|
||||
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
|
||||
const rolesSpyCheckUserRoleAccess = jest.spyOn(
|
||||
RolesGuard.prototype as unknown as RolesGuard,
|
||||
"checkUserRoleAccess"
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.organizationId).toEqual(organization.id);
|
||||
|
||||
expect(pbacSpyCanActivate).toHaveBeenCalled();
|
||||
expect(rolesSpyCanActivate).toHaveBeenCalled();
|
||||
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
|
||||
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
|
||||
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
|
||||
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
|
||||
expect(pbacCanActivateResult).toBe(true);
|
||||
expect(rolesCanActivateResult).toBe(true);
|
||||
expect(hasPbac).toBe(false);
|
||||
expect(rolesSpyCheckUserRoleAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative Tests", () => {
|
||||
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role No Role",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const userWithNoRole = await userRepositoryFixture.create({
|
||||
email: `no-role-user-${randomString()}@api.com`,
|
||||
username: `no-role-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: userWithNoRole.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
userWithNoRole.id,
|
||||
null
|
||||
);
|
||||
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${noRoleApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - user with id=${userWithNoRole.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role No Permission",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - user with id=${pbacOrgUserWithoutRolePermission.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role No Membership",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - User is not a member of the organization with id=${organization.id}.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "Test Role Member Only",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`RolesGuard - user with id=${legacyOrgMemberUser.id} does not have the minimum required role=ORG_ADMIN within organization with id=${organization.id}.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRUD Role Endpoints", () => {
|
||||
let createdRoleId: string;
|
||||
|
||||
it("should create a role", async () => {
|
||||
const createRoleInput: CreateOrgRoleInput = {
|
||||
name: "CRUD Test Role",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
createdRoleId = responseBody.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update role permissions and name", async () => {
|
||||
const updateRoleInput: UpdateOrgRoleInput = {
|
||||
name: "CRUD Test Role Updated",
|
||||
permissions: ["booking.read", "eventType.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.name).toEqual(updateRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch all roles", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetAllOrgRolesOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
|
||||
const created = responseBody.data.find((r) => r.id === createdRoleId);
|
||||
expect(created?.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: DeleteOrgRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative error cases", () => {
|
||||
it("should fail to create a role with a duplicate name (400)", async () => {
|
||||
const name = `dup-role-${randomString()}`;
|
||||
|
||||
const firstCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(firstCreate)
|
||||
.expect(201);
|
||||
|
||||
const secondCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(secondCreate);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(`Role with name "${name}" already exists`);
|
||||
});
|
||||
|
||||
it("should fail to create a role with invalid permissions (400)", async () => {
|
||||
const createRoleInput = {
|
||||
name: `invalid-perms-${randomString()}`,
|
||||
permissions: ["invalid"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when updating a role not belonging to the organization", async () => {
|
||||
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
|
||||
const updateRoleInput: UpdateOrgRoleInput = {
|
||||
name: `no-update-default-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when updating a default (system) role not belonging to the organization", async () => {
|
||||
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
|
||||
const updateRoleInput: UpdateOrgRoleInput = {
|
||||
name: `no-update-default-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail to update with invalid permissions (400)", async () => {
|
||||
// Create a fresh role to update
|
||||
const { body: createRes } = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ name: `update-invalid-${randomString()}` as string, permissions: ["booking.read"] })
|
||||
.expect(201);
|
||||
const roleId: string = createRes.data.id;
|
||||
|
||||
const updateRoleInput = {
|
||||
name: `update-invalid-${randomString()}`,
|
||||
permissions: ["invalid"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when updating a role from a different organization", async () => {
|
||||
// Create a role in a different org (legacy/non-PBAC org)
|
||||
const foreignRole = await roleService.createRole({
|
||||
name: `foreign-update-${randomString()}`,
|
||||
teamId: organization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["eventType.read"] });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when deleting a role not belonging to the organization", async () => {
|
||||
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when deleting a default (system) role not belonging to the organization", async () => {
|
||||
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 404 when deleting a role from a different organization", async () => {
|
||||
// Create a role in a different org
|
||||
const foreignRole = await roleService.createRole({
|
||||
name: `foreign-delete-${randomString()}`,
|
||||
teamId: organization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
|
||||
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
|
||||
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
|
||||
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
|
||||
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
|
||||
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
|
||||
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
|
||||
import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service";
|
||||
import { RolesService } from "@/modules/roles/services/roles.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/roles",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Roles")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsRolesController {
|
||||
constructor(
|
||||
private readonly rolesService: RolesService,
|
||||
private readonly rolesOutputService: OrganizationsRolesOutputService
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.create"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a new organization role" })
|
||||
async createRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgRoleInput
|
||||
): Promise<CreateOrgRoleOutput> {
|
||||
const role = await this.rolesService.createRole(orgId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get a specific organization role" })
|
||||
async getRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetOrgRoleOutput> {
|
||||
const role = await this.rolesService.getRole(orgId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get all organization roles" })
|
||||
async getAllRoles(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetAllOrgRolesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const roles = await this.rolesService.getTeamRoles(orgId, skip ?? 0, take ?? 250);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRolesOutput(roles),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Patch("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an organization role" })
|
||||
async updateRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: UpdateOrgRoleInput
|
||||
): Promise<UpdateOrgRoleOutput> {
|
||||
const role = await this.rolesService.updateRole(orgId, roleId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.delete"])
|
||||
@Delete("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete an organization role" })
|
||||
async deleteRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<DeleteOrgRoleOutput> {
|
||||
const role = await this.rolesService.deleteRole(orgId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsRolesPermissionsController } from "@/modules/organizations/roles/permissions/organizations-roles-permissions.controller";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsRolesController } from "./organizations-roles.controller";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
|
||||
providers: [OrganizationsRepository],
|
||||
controllers: [OrganizationsRolesController, OrganizationsRolesPermissionsController],
|
||||
})
|
||||
export class OrganizationsRolesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsArray, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetAllOrgRolesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: [OrgRoleOutput],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
|
||||
|
||||
import { orgPermissionEnum } from "../inputs/base-org-role.input";
|
||||
|
||||
enum RoleTypeEnum {
|
||||
SYSTEM = "SYSTEM",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
type RoleType = keyof typeof RoleTypeEnum;
|
||||
|
||||
export class OrgRoleOutput {
|
||||
@ApiProperty({ description: "Unique identifier for the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: "Name of the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
color?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Organization ID this role belongs to" })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
organizationId?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Type of role",
|
||||
enum: RoleTypeEnum,
|
||||
})
|
||||
@IsEnum(RoleTypeEnum)
|
||||
@Expose()
|
||||
type!: RoleType;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Permissions assigned to this role in 'resource.action' format.",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["booking.read", "eventType.create"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Expose()
|
||||
permissions!: string[];
|
||||
|
||||
@ApiProperty({ description: "When the role was created" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ description: "When the role was last updated" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
updatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateOrgRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgRoleOutput)
|
||||
data!: OrgRoleOutput;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
|
||||
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
|
||||
|
||||
export class CreateOrgRolePermissionsInput {
|
||||
@ApiProperty({
|
||||
description: "Permissions to add (format: resource.action)",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(OrgPermissionStringValidator, { each: true })
|
||||
permissions!: PermissionString[];
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
|
||||
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
|
||||
|
||||
export class DeleteOrgRolePermissionsQuery {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
|
||||
example: "?permissions=eventType.read,booking.read",
|
||||
enum: orgPermissionEnum,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((p: string) => p.trim())
|
||||
.filter((p: string) => p.length > 0);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: "permissions cannot be empty." })
|
||||
@IsString({ each: true })
|
||||
@Validate(OrgPermissionStringValidator, { each: true })
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { OrgPermissionStringValidator } from "./org-permission-string.validator";
|
||||
|
||||
describe("PermissionStringValidator", () => {
|
||||
let validator: OrgPermissionStringValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new OrgPermissionStringValidator();
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("should return true for valid permission strings", () => {
|
||||
const validPermissions = [
|
||||
"eventType.read",
|
||||
"eventType.create",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
"booking.read",
|
||||
"booking.update",
|
||||
"role.create",
|
||||
"role.read",
|
||||
"role.update",
|
||||
"role.delete",
|
||||
"team.read",
|
||||
"team.invite",
|
||||
"organization.read",
|
||||
"organization.listMembers",
|
||||
// no wildcards here
|
||||
];
|
||||
|
||||
validPermissions.forEach((permission) => {
|
||||
expect(validator.validate(permission)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for invalid permission strings", () => {
|
||||
const invalidPermissions = [
|
||||
"invalid", // no dot
|
||||
"invalid.", // no action
|
||||
".invalid", // no resource
|
||||
"invalid.action", // invalid resource
|
||||
"eventType.invalid", // invalid action
|
||||
"event-type.read", // wrong format (should be eventType)
|
||||
"", // empty string
|
||||
"eventType..read", // double dot
|
||||
"eventType.read.extra", // too many parts
|
||||
];
|
||||
|
||||
invalidPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for wildcard permission strings", () => {
|
||||
const wildcardPermissions = ["*", "*.read", "eventType.*"];
|
||||
|
||||
wildcardPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultMessage", () => {
|
||||
it("should return appropriate error message", () => {
|
||||
const message = validator.defaultMessage();
|
||||
expect(message).toContain("Permission must be a valid permission string");
|
||||
expect(message).toContain("resource.action");
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
|
||||
import { Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@ValidatorConstraint({ name: "orgPermissionStringValidator", async: false })
|
||||
export class OrgPermissionStringValidator implements ValidatorConstraintInterface {
|
||||
validate(permission: string) {
|
||||
const isValid = isValidPermissionStringForScope(permission, Scope.Organization);
|
||||
if (!isValid) {
|
||||
throw new BadRequestException(
|
||||
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
|
||||
}
|
||||
}
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
describe("Organizations Roles Permissions Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
|
||||
let pbacEnabledOrganization: Team;
|
||||
|
||||
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create PBAC org
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-role-perm-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacUserEmail,
|
||||
username: pbacUserEmail,
|
||||
});
|
||||
|
||||
const membership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
// Create a role that allows role.read and role.update, assign to user
|
||||
const managerRole = await roleService.createRole({
|
||||
name: `role-manager-${randomString()}`,
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
await roleService.assignRoleToMember(managerRole.id, membership.id);
|
||||
|
||||
// API key for user
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Negative Tests", () => {
|
||||
it("rejects invalid permission on add (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-add-invalid-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["invalid"] });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid permission on replace (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-put-invalid-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["invalid"] });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid permission on delete single (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-delone-invalid-${randomString()}`,
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${"invalid"}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid permission on bulk delete (400)", async () => {
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `neg-delmany-invalid-${randomString()}`,
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const roleId = createRes.body.data.id as string;
|
||||
|
||||
const invalid = "invalid";
|
||||
const res = await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${
|
||||
pbacEnabledOrganization.id
|
||||
}/roles/${roleId}/permissions?permissions=${encodeURIComponent(`booking.read,${invalid}`)}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.message).toBe(
|
||||
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 when modifying permissions for role not belonging to organization", async () => {
|
||||
// Create foreign organization and role
|
||||
const foreignOrg = await organizationsRepositoryFixture.create({
|
||||
name: `foreign-org-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
const foreignRole = await roleService.createRole({
|
||||
name: `foreign-role-${randomString()}`,
|
||||
teamId: foreignOrg.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["eventType.read"] });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
|
||||
await organizationsRepositoryFixture.delete(foreignOrg.id);
|
||||
});
|
||||
|
||||
it("returns 404 when modifying permissions of a default (system) role not belonging to organization", async () => {
|
||||
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: ["eventType.read"] });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists permissions for a role (GET /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-list-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(listRes.body.data).toEqual(initialPermissions);
|
||||
});
|
||||
|
||||
it("adds permissions (POST /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const toAdd = ["eventType.create", "eventType.read"] as const;
|
||||
const expected = [...initialPermissions, ...toAdd] as string[];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-add-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const addRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: toAdd })
|
||||
.expect(200);
|
||||
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(addRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("bulk removes permissions via query (DELETE /)", async () => {
|
||||
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
|
||||
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
|
||||
const expected: PermissionString[] = ["booking.read"];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delmany-${randomString()}`,
|
||||
permissions: initialPermissions,
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${
|
||||
pbacEnabledOrganization.id
|
||||
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("replaces all permissions (PUT /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const replacement = ["booking.read", "eventType.update"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-put-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const putRes = await request(app.getHttpServer())
|
||||
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: replacement })
|
||||
.expect(200);
|
||||
|
||||
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(putRes.body.data).toEqual(replacement);
|
||||
});
|
||||
|
||||
it("removes a single permission (DELETE /:permission)", async () => {
|
||||
const initialPermissions = ["booking.read", "eventType.update"] as const;
|
||||
const toRemove = "eventType.update" as const;
|
||||
const expected = ["booking.read"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delone-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${toRemove}`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
// Clean up feature flag from organization
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
|
||||
|
||||
// Clean up org
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
// Clean up user
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
|
||||
// Clean up feature definition
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgRolePermissionsInput } from "@/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input";
|
||||
import { DeleteOrgRolePermissionsQuery } from "@/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query";
|
||||
import { GetOrgRolePermissionsOutput } from "@/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output";
|
||||
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/roles/:roleId/permissions",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Roles / Permissions")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsRolesPermissionsController {
|
||||
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Add permissions to an organization role (single or batch)" })
|
||||
async addPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateOrgRolePermissionsInput
|
||||
): Promise<GetOrgRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.addRolePermissions(orgId, roleId, body.permissions);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List permissions for an organization role" })
|
||||
async listPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetOrgRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.getRolePermissions(orgId, roleId);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Put("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Replace all permissions for an organization role" })
|
||||
async setPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateOrgRolePermissionsInput
|
||||
): Promise<GetOrgRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.setRolePermissions(
|
||||
orgId,
|
||||
roleId,
|
||||
body.permissions || []
|
||||
);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/:permission")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove a permission from an organization role" })
|
||||
async removePermission(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Param("permission") permission: PermissionString
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermission(orgId, roleId, permission);
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove multiple permissions from an organization role" })
|
||||
async removePermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Query() query: DeleteOrgRolePermissionsQuery
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermissions(orgId, roleId, query.permissions || []);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetOrgRolePermissionsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
data!: string[];
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Role } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRolesOutputService {
|
||||
getOrganizationRoleOutput(role: Role): OrgRoleOutput {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color || null,
|
||||
description: role.description || null,
|
||||
organizationId: role.teamId || null,
|
||||
type: role.type,
|
||||
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getOrganizationRolesOutput(roles: Role[]): TeamRoleOutput[] {
|
||||
return roles.map((role) => this.getOrganizationRoleOutput(role));
|
||||
}
|
||||
}
|
||||
+203
@@ -418,6 +418,209 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
// Auto-accept tests
|
||||
describe("auto-accept based on email domain", () => {
|
||||
let orgWithAutoAccept: Team;
|
||||
let subteamWithAutoAccept: Team;
|
||||
let userWithMatchingEmail: User;
|
||||
let userWithUppercaseEmail: User;
|
||||
let userWithMatchingEmailForOverride: User;
|
||||
let userWithNonMatchingEmail: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create org with auto-accept settings
|
||||
orgWithAutoAccept = await organizationsRepositoryFixture.create({
|
||||
name: `auto-accept-org-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
// Update organizationSettings with orgAutoAcceptEmail
|
||||
await organizationsRepositoryFixture.updateSettings(orgWithAutoAccept.id, {
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isOrganizationVerified: true,
|
||||
isOrganizationConfigured: true,
|
||||
});
|
||||
|
||||
// Create subteam
|
||||
subteamWithAutoAccept = await teamsRepositoryFixture.create({
|
||||
name: `auto-accept-subteam-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
// Create event type with assignAllTeamMembers
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: subteamWithAutoAccept.id } },
|
||||
title: "Auto Accept Event Type",
|
||||
slug: "auto-accept-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Create users
|
||||
userWithMatchingEmail = await userRepositoryFixture.create({
|
||||
email: `alice@acme.com`,
|
||||
username: `alice-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithUppercaseEmail = await userRepositoryFixture.create({
|
||||
email: `bob@ACME.COM`,
|
||||
username: `bob-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithMatchingEmailForOverride = await userRepositoryFixture.create({
|
||||
email: `david@acme.com`,
|
||||
username: `david-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithNonMatchingEmail = await userRepositoryFixture.create({
|
||||
email: `charlie@external.com`,
|
||||
username: `charlie-${randomString()}`,
|
||||
});
|
||||
|
||||
// Add users to org
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithMatchingEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
// Create profiles for users
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithMatchingEmail.id}`,
|
||||
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithMatchingEmail.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithUppercaseEmail.id}`,
|
||||
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithMatchingEmailForOverride.id}`,
|
||||
username: userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithNonMatchingEmail.id}`,
|
||||
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||
});
|
||||
|
||||
// Make user an admin of the org for API access
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should auto-accept when email matches orgAutoAcceptEmail", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithMatchingEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
|
||||
// Verify EventTypes assignment
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(
|
||||
subteamWithAutoAccept.id
|
||||
);
|
||||
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
|
||||
expect(eventTypeWithAssignAll).toBeTruthy();
|
||||
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
|
||||
expect(userIsHost).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle case-insensitive email domain matching", async () => {
|
||||
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithUppercaseEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("should ALWAYS auto-accept when email matches, even if accepted:false", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithMatchingEmailForOverride.id,
|
||||
role: "MEMBER",
|
||||
accepted: false,
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
// Should override to true because email matches
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("should NOT auto-accept when email does not match orgAutoAcceptEmail", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithNonMatchingEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.data.accepted).toBe(false);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
|
||||
await organizationsRepositoryFixture.delete(orgWithAutoAccept.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
|
||||
+22
-3
@@ -12,6 +12,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
||||
@@ -58,8 +59,9 @@ export class OrganizationsTeamsMembershipsController {
|
||||
|
||||
constructor(
|
||||
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly orgMembershipService: OrganizationMembershipService
|
||||
) { }
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all memberships" })
|
||||
@@ -168,6 +170,9 @@ export class OrganizationsTeamsMembershipsController {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// TODO: Refactor to use inviteMembersWithNoInviterPermissionCheck when it is moved to a Service
|
||||
// See: packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@@ -184,7 +189,21 @@ export class OrganizationsTeamsMembershipsController {
|
||||
throw new UnprocessableEntityException("User is not part of the Organization");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
|
||||
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
|
||||
organizationId: orgId,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
// ALWAYS override when email matches - prevents pending memberships
|
||||
// Remember organizations expect added team member to automatically start receiving bookings for the team event
|
||||
const acceptedStatus = shouldAutoAccept ? true : (data.accepted ?? false);
|
||||
|
||||
const membershipData = { ...data, accepted: acceptedStatus };
|
||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(
|
||||
teamId,
|
||||
membershipData
|
||||
);
|
||||
|
||||
if (membership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(user.id, teamId);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { TeamPermissionStringValidator } from "../permissions/inputs/validators/team-permission-string.validator";
|
||||
|
||||
export const teamPermissionEnum = [...getAllPermissionStringsForScope(Scope.Team)] as const;
|
||||
|
||||
export class BaseTeamRoleInput {
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "eventType.create", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(TeamPermissionStringValidator, { each: true })
|
||||
@IsOptional()
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
import { BaseTeamRoleInput } from "./base-team-role.input";
|
||||
|
||||
export class CreateTeamRoleInput extends BaseTeamRoleInput {
|
||||
@ApiProperty({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsString, IsOptional, MinLength } from "class-validator";
|
||||
|
||||
import { BaseTeamRoleInput } from "./base-team-role.input";
|
||||
|
||||
export class UpdateTeamRoleInput extends BaseTeamRoleInput {
|
||||
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
|
||||
import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output";
|
||||
import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output";
|
||||
import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output";
|
||||
import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Roles Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
// Test users
|
||||
let legacyOrgAdminUser: User;
|
||||
let legacyOrgMemberUser: User;
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithoutRolePermission: User;
|
||||
let nonOrgUser: User;
|
||||
|
||||
// API Keys
|
||||
let legacyOrgAdminApiKey: string;
|
||||
let legacyOrgMemberApiKey: string;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
let pbacOrgUserWithoutRolePermissionApiKey: string;
|
||||
let nonOrgUserApiKey: string;
|
||||
|
||||
// Organization and team
|
||||
let organization: Team;
|
||||
let team: Team;
|
||||
let pbacEnabledOrganization: Team;
|
||||
let pbacEnabledTeam: Team;
|
||||
|
||||
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
|
||||
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
|
||||
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
|
||||
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create test users
|
||||
legacyOrgAdminUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgAdminEmail,
|
||||
username: legacyOrgAdminEmail,
|
||||
});
|
||||
|
||||
legacyOrgMemberUser = await userRepositoryFixture.create({
|
||||
email: legacyOrgMemberEmail,
|
||||
username: legacyOrgMemberEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithRolePermissionEmail,
|
||||
username: pbacOrgUserWithRolePermissionEmail,
|
||||
});
|
||||
|
||||
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacOrgUserWithoutRolePermissionEmail,
|
||||
username: pbacOrgUserWithoutRolePermissionEmail,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
});
|
||||
|
||||
// Create organizations and teams
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `team-roles-test-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-roles-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
pbacEnabledTeam = await teamRepositoryFixture.create({
|
||||
name: `pbac-team-roles-test-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
// Create memberships
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: legacyOrgAdminUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: legacyOrgMemberUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const roleWithPermission = await roleService.createRole({
|
||||
name: "Role Manager",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update", "role.delete"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
const roleWithoutPermission = await roleService.createRole({
|
||||
name: "Basic Role",
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["booking.read"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
|
||||
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
|
||||
await roleService.assignRoleToMember(
|
||||
roleWithoutPermission.id,
|
||||
pbacOrgUserWithoutRolePermissionMembership.id
|
||||
);
|
||||
|
||||
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgAdminUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
|
||||
|
||||
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
legacyOrgMemberUser.id,
|
||||
null
|
||||
);
|
||||
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
pbacOrgUserWithRolePermission.id,
|
||||
null
|
||||
);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
|
||||
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
|
||||
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
|
||||
|
||||
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
nonOrgUser.id,
|
||||
null
|
||||
);
|
||||
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Role Creation Authorization", () => {
|
||||
describe("Positive Tests", () => {
|
||||
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role PBAC",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role Legacy Admin",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.teamId).toEqual(team.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative Tests", () => {
|
||||
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role No Role",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
const userWithNoRole = await userRepositoryFixture.create({
|
||||
email: `no-role-user-${randomString()}@api.com`,
|
||||
username: `no-role-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: userWithNoRole.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
userWithNoRole.id,
|
||||
null
|
||||
);
|
||||
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${noRoleApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role No Permission",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role No Membership",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
|
||||
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "Test Role Member Only",
|
||||
permissions: ["booking.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
|
||||
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CRUD Role Endpoints", () => {
|
||||
let createdRoleId: string;
|
||||
|
||||
it("should create a role", async () => {
|
||||
const createRoleInput: CreateTeamRoleInput = {
|
||||
name: "CRUD Test Role",
|
||||
permissions: ["booking.read", "eventType.create"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(createRoleInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
createdRoleId = responseBody.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update role permissions and name", async () => {
|
||||
const updateRoleInput: UpdateTeamRoleInput = {
|
||||
name: "CRUD Test Role Updated",
|
||||
permissions: ["booking.read", "eventType.read"],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateRoleInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.name).toEqual(updateRoleInput.name);
|
||||
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update only name and keep permissions unchanged", async () => {
|
||||
const updateNameOnly: UpdateTeamRoleInput = {
|
||||
name: "CRUD Test Role Renamed Only",
|
||||
};
|
||||
const expectedPermissions = ["booking.read", "eventType.read"]; // from previous update
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(updateNameOnly)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.name).toEqual(updateNameOnly.name);
|
||||
expect(responseBody.data.permissions).toEqual(expectedPermissions);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch all roles", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetAllTeamRolesOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
|
||||
const created = responseBody.data.find((r) => r.id === createdRoleId);
|
||||
expect(created?.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the role", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: DeleteTeamRoleOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await teamRepositoryFixture.delete(pbacEnabledTeam.id);
|
||||
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cleanup error:", error);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
|
||||
import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output";
|
||||
import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output";
|
||||
import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output";
|
||||
import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output";
|
||||
import { TeamRolesOutputService } from "@/modules/organizations/teams/roles/services/team-roles-output.service";
|
||||
import { RolesService } from "@/modules/roles/services/roles.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/roles",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(
|
||||
ApiAuthGuard,
|
||||
IsOrgGuard,
|
||||
PbacGuard,
|
||||
RolesGuard,
|
||||
IsTeamInOrg,
|
||||
PlatformPlanGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@DocsTags("Orgs / Teams / Roles")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsRolesController {
|
||||
constructor(
|
||||
private readonly rolesService: RolesService,
|
||||
private readonly rolesOutputService: TeamRolesOutputService
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.create"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a new organization team role" })
|
||||
async createRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateTeamRoleInput
|
||||
): Promise<CreateTeamRoleOutput> {
|
||||
const role = await this.rolesService.createRole(teamId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get a specific organization team role" })
|
||||
async getRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetTeamRoleOutput> {
|
||||
const role = await this.rolesService.getRole(teamId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get all organization team roles" })
|
||||
async getAllRoles(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetAllTeamRolesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const roles = await this.rolesService.getTeamRoles(teamId, skip ?? 0, take ?? 250);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRolesOutput(roles),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Patch("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an organization team role" })
|
||||
async updateRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: UpdateTeamRoleInput
|
||||
): Promise<UpdateTeamRoleOutput> {
|
||||
const role = await this.rolesService.updateRole(teamId, roleId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.delete"])
|
||||
@Delete("/:roleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete an organization team role" })
|
||||
async deleteRole(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<DeleteTeamRoleOutput> {
|
||||
const role = await this.rolesService.deleteRole(teamId, roleId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.rolesOutputService.getTeamRoleOutput(role),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsRolesPermissionsController } from "@/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRolesController } from "./organizations-teams-roles.controller";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
|
||||
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
|
||||
controllers: [OrganizationsTeamsRolesController, OrganizationsTeamsRolesPermissionsController],
|
||||
})
|
||||
export class OrganizationsTeamsRolesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsArray, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetAllTeamRolesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: [TeamRoleOutput],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
|
||||
|
||||
import { teamPermissionEnum } from "../inputs/base-team-role.input";
|
||||
|
||||
enum RoleTypeEnum {
|
||||
SYSTEM = "SYSTEM",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
type RoleType = keyof typeof RoleTypeEnum;
|
||||
|
||||
export class TeamRoleOutput {
|
||||
@ApiProperty({ description: "Unique identifier for the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: "Name of the role" })
|
||||
@IsString()
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
color?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description of the role" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: "Team ID this role belongs to" })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
teamId?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Type of role",
|
||||
enum: RoleTypeEnum,
|
||||
})
|
||||
@IsEnum(RoleTypeEnum)
|
||||
@Expose()
|
||||
type!: RoleType;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Permissions assigned to this role in 'resource.action' format.",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["booking.read", "eventType.create"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Expose()
|
||||
permissions!: string[];
|
||||
|
||||
@ApiProperty({ description: "When the role was created" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ description: "When the role was last updated" })
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
updatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateTeamRoleOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamRoleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamRoleOutput)
|
||||
data!: TeamRoleOutput;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { teamPermissionEnum } from "../../inputs/base-team-role.input";
|
||||
import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator";
|
||||
|
||||
export class CreateTeamRolePermissionsInput {
|
||||
@ApiProperty({
|
||||
description: "Permissions to add (format: resource.action)",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
example: ["eventType.read", "booking.read"],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Validate(TeamPermissionStringValidator, { each: true })
|
||||
permissions!: PermissionString[];
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { teamPermissionEnum } from "../../inputs/base-team-role.input";
|
||||
import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator";
|
||||
|
||||
export class DeleteTeamRolePermissionsQuery {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
|
||||
example: "?permissions=eventType.read,booking.read",
|
||||
enum: teamPermissionEnum,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((p: string) => p.trim())
|
||||
.filter((p: string) => p.length > 0);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: "permissions cannot be empty." })
|
||||
@IsString({ each: true })
|
||||
@Validate(TeamPermissionStringValidator, { each: true })
|
||||
permissions?: PermissionString[];
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { TeamPermissionStringValidator } from "./team-permission-string.validator";
|
||||
|
||||
describe("PermissionStringValidator", () => {
|
||||
let validator: TeamPermissionStringValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new TeamPermissionStringValidator();
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("should return true for valid permission strings", () => {
|
||||
const validPermissions = [
|
||||
"eventType.read",
|
||||
"eventType.create",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
"booking.read",
|
||||
"booking.update",
|
||||
"role.create",
|
||||
"role.read",
|
||||
"role.update",
|
||||
"role.delete",
|
||||
"team.read",
|
||||
"team.invite",
|
||||
];
|
||||
|
||||
validPermissions.forEach((permission) => {
|
||||
expect(validator.validate(permission)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for wildcard permission strings", () => {
|
||||
const wildcardPermissions = ["*", "*.read", "eventType.*"];
|
||||
|
||||
wildcardPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for invalid permission strings", () => {
|
||||
const invalidPermissions = [
|
||||
"invalid", // no dot
|
||||
"invalid.", // no action
|
||||
".invalid", // no resource
|
||||
"invalid.action", // invalid resource
|
||||
"eventType.invalid", // invalid action
|
||||
"event-type.read", // wrong format (should be eventType)
|
||||
"", // empty string
|
||||
"eventType..read", // double dot
|
||||
"eventType.read.extra", // too many parts
|
||||
];
|
||||
|
||||
invalidPermissions.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw for organization-scoped permission strings", () => {
|
||||
// These are explicitly marked with scope: [Scope.Organization] in permission-registry
|
||||
const orgScoped = [
|
||||
"organization.create",
|
||||
"organization.read",
|
||||
"organization.listMembers",
|
||||
"organization.listMembersPrivate",
|
||||
"organization.invite",
|
||||
"organization.remove",
|
||||
"organization.manageBilling",
|
||||
"organization.changeMemberRole",
|
||||
"organization.impersonate",
|
||||
"organization.update",
|
||||
];
|
||||
|
||||
orgScoped.forEach((permission) => {
|
||||
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultMessage", () => {
|
||||
it("should return appropriate error message", () => {
|
||||
const message = validator.defaultMessage();
|
||||
expect(message).toContain("Permission must be a valid permission string");
|
||||
expect(message).toContain("resource.action");
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
|
||||
import { Scope } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@ValidatorConstraint({ name: "teamPermissionStringValidator", async: false })
|
||||
export class TeamPermissionStringValidator implements ValidatorConstraintInterface {
|
||||
validate(permission: string) {
|
||||
const isValid = isValidPermissionStringForScope(permission, Scope.Team);
|
||||
if (!isValid) {
|
||||
throw new BadRequestException(
|
||||
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Roles Permissions Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||
let roleService: RoleService;
|
||||
|
||||
let pbacOrgUserWithRolePermission: User;
|
||||
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||
|
||||
let pbacEnabledOrganization: Team;
|
||||
let pbacEnabledTeam: Team;
|
||||
|
||||
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||
roleService = new RoleService();
|
||||
|
||||
// Create PBAC org/team
|
||||
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||
name: `pbac-org-role-perm-test-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
pbacEnabledTeam = await teamRepositoryFixture.create({
|
||||
name: `pbac-team-role-perm-test-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
// Create user + membership in org
|
||||
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||
email: pbacUserEmail,
|
||||
username: pbacUserEmail,
|
||||
});
|
||||
|
||||
const membership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||
});
|
||||
|
||||
// Create a role that allows role.read and role.update, assign to user
|
||||
const managerRole = await roleService.createRole({
|
||||
name: `role-manager-${randomString()}`,
|
||||
teamId: pbacEnabledOrganization.id,
|
||||
permissions: ["role.create", "role.read", "role.update"],
|
||||
type: "CUSTOM",
|
||||
});
|
||||
await roleService.assignRoleToMember(managerRole.id, membership.id);
|
||||
|
||||
// API key for user
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
|
||||
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("lists permissions for a role (GET /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-list-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(listRes.body.data).toEqual(initialPermissions);
|
||||
});
|
||||
|
||||
it("adds permissions (POST /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const toAdd = ["eventType.create", "eventType.read"] as const;
|
||||
const expected = [...initialPermissions, ...toAdd] as string[];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-add-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const addRes = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: toAdd })
|
||||
.expect(200);
|
||||
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(addRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("bulk removes permissions via query (DELETE /)", async () => {
|
||||
// Seed role
|
||||
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
|
||||
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
|
||||
const expected: PermissionString[] = ["booking.read"];
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delmany-${randomString()}`,
|
||||
permissions: initialPermissions,
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${
|
||||
pbacEnabledTeam.id
|
||||
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it("replaces all permissions (PUT /)", async () => {
|
||||
const initialPermissions = ["booking.read"] as const;
|
||||
const replacement = ["booking.read", "eventType.update"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-put-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
const putRes = await request(app.getHttpServer())
|
||||
.put(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send({ permissions: replacement })
|
||||
.expect(200);
|
||||
|
||||
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(putRes.body.data).toEqual(replacement);
|
||||
});
|
||||
|
||||
it("removes a single permission (DELETE /:permission)", async () => {
|
||||
const initialPermissions = ["booking.read", "eventType.update"] as const;
|
||||
const toRemove = "eventType.update" as const;
|
||||
const expected = ["booking.read"] as const;
|
||||
|
||||
const baseRoleInput: CreateTeamRoleInput = {
|
||||
name: `perm-target-role-delone-${randomString()}`,
|
||||
permissions: [...initialPermissions],
|
||||
};
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.send(baseRoleInput)
|
||||
.expect(201);
|
||||
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||
const roleId = responseBody.data.id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions/${toRemove}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(204);
|
||||
|
||||
const listRes = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
|
||||
)
|
||||
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||
.expect(200);
|
||||
expect(listRes.body.data).toEqual(expected);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
// Clean up feature flag from team
|
||||
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac");
|
||||
|
||||
// Clean up teams and orgs
|
||||
await teamRepositoryFixture.delete(pbacEnabledTeam.id);
|
||||
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||
|
||||
// Clean up user
|
||||
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||
|
||||
// Clean up feature definition
|
||||
try {
|
||||
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateTeamRolePermissionsInput } from "@/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input";
|
||||
import { DeleteTeamRolePermissionsQuery } from "@/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query";
|
||||
import { GetTeamRolePermissionsOutput } from "@/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output";
|
||||
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/roles/:roleId/permissions",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(
|
||||
ApiAuthGuard,
|
||||
IsOrgGuard,
|
||||
PbacGuard,
|
||||
RolesGuard,
|
||||
IsTeamInOrg,
|
||||
PlatformPlanGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@DocsTags("Orgs / Teams / Roles / Permissions")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsRolesPermissionsController {
|
||||
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Add permissions to an organization team role (single or batch)" })
|
||||
async addPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateTeamRolePermissionsInput
|
||||
): Promise<GetTeamRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.addRolePermissions(
|
||||
teamId,
|
||||
roleId,
|
||||
body.permissions
|
||||
);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.read"])
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List permissions for an organization team role" })
|
||||
async listPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string
|
||||
): Promise<GetTeamRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.getRolePermissions(teamId, roleId);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Put("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Replace all permissions for an organization team role" })
|
||||
async setPermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Body() body: CreateTeamRolePermissionsInput
|
||||
): Promise<GetTeamRolePermissionsOutput> {
|
||||
const permissions = await this.rolePermissionsService.setRolePermissions(
|
||||
teamId,
|
||||
roleId,
|
||||
body.permissions || []
|
||||
);
|
||||
return { status: SUCCESS_STATUS, data: permissions };
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/:permission")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove a permission from an organization team role" })
|
||||
async removePermission(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Param("permission") permission: PermissionString
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermission(teamId, roleId, permission);
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@Pbac(["role.update"])
|
||||
@Delete("/")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove multiple permissions from an organization team role" })
|
||||
async removePermissions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("roleId") roleId: string,
|
||||
@Query() query: DeleteTeamRolePermissionsQuery
|
||||
): Promise<void> {
|
||||
await this.rolePermissionsService.removeRolePermissions(teamId, roleId, query.permissions || []);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetTeamRolePermissionsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
data!: string[];
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Role } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class TeamRolesOutputService {
|
||||
getTeamRoleOutput(role: Role): TeamRoleOutput {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color || null,
|
||||
description: role.description || null,
|
||||
teamId: role.teamId || null,
|
||||
type: role.type,
|
||||
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getTeamRolesOutput(roles: Role[]): TeamRoleOutput[] {
|
||||
return roles.map((role) => this.getTeamRoleOutput(role));
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export class RedisService implements OnModuleDestroy {
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as TData;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return data as TData;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,30 @@ export class RedisService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async getKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.isReady) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return this.redis.keys(pattern);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis getKeys failed: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async delMany(keys: string[]): Promise<number> {
|
||||
if (!this.isReady || keys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return this.redis.del(...keys);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis delMany failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
|
||||
if (!this.isReady) {
|
||||
return null;
|
||||
@@ -137,4 +161,16 @@ export class RedisService implements OnModuleDestroy {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async incr(key: string): Promise<number> {
|
||||
if (!this.isReady) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return this.redis.incr(key);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis incr failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
export const REDIS_TEAM_PERMISSIONS_CACHE_PATTERN = (teamId: number) =>
|
||||
`apiv2:user:*:team:${teamId}:requiredPermissions:*:guard:pbac`;
|
||||
|
||||
@Injectable()
|
||||
export class RolesPermissionsCacheService {
|
||||
private readonly logger = new Logger(RolesPermissionsCacheService.name);
|
||||
|
||||
constructor(private readonly redisService: RedisService) {}
|
||||
|
||||
async deleteTeamPermissionsCache(teamId: number): Promise<void> {
|
||||
try {
|
||||
const pattern = REDIS_TEAM_PERMISSIONS_CACHE_PATTERN(teamId);
|
||||
const keys = await this.redisService.getKeys(pattern);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await this.redisService.delMany(keys);
|
||||
this.logger.log(`Deleted ${keys.length} permission cache keys for team ${teamId}`);
|
||||
} else {
|
||||
this.logger.log(`No permission cache keys found for team ${teamId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete permission cache for team ${teamId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { PermissionString, Role } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class RolesPermissionsOutputService {
|
||||
getPermissionsFromRole(role: Role): PermissionString[] {
|
||||
return role.permissions.map(
|
||||
(permission) => `${permission.resource}.${permission.action}` as PermissionString
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { RolesPermissionsCacheService } from "@/modules/roles/permissions/services/roles-permissions-cache.service";
|
||||
import { RolesPermissionsOutputService } from "@/modules/roles/permissions/services/roles-permissions-output.service";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import type { PermissionString, UpdateRolePermissionsData } from "@calcom/platform-libraries/pbac";
|
||||
import { RoleService, isValidPermissionString } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class RolesPermissionsService {
|
||||
constructor(
|
||||
private readonly roleService: RoleService,
|
||||
private readonly rolesPermissionsOutputService: RolesPermissionsOutputService,
|
||||
private readonly rolesPermissionsCacheService: RolesPermissionsCacheService
|
||||
) {}
|
||||
|
||||
async getRolePermissions(teamId: number, roleId: string) {
|
||||
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const role = await this.roleService.getRole(roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
return this.rolesPermissionsOutputService.getPermissionsFromRole(role);
|
||||
}
|
||||
|
||||
async addRolePermissions(teamId: number, roleId: string, permissionsToAdd: PermissionString[]) {
|
||||
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const current = await this.getRolePermissions(teamId, roleId);
|
||||
const desired = Array.from(new Set([...(current || []), ...(permissionsToAdd || [])]));
|
||||
|
||||
const updateData: UpdateRolePermissionsData = {
|
||||
roleId,
|
||||
permissions: desired,
|
||||
updates: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const updatedRole = await this.roleService.update(updateData);
|
||||
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
|
||||
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Invalid permissions provided")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeRolePermission(teamId: number, roleId: string, permissionToRemove: PermissionString) {
|
||||
if (!isValidPermissionString(permissionToRemove)) {
|
||||
throw new BadRequestException(
|
||||
`Permission '${permissionToRemove}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||
);
|
||||
}
|
||||
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const current = await this.getRolePermissions(teamId, roleId);
|
||||
const desired = (current || []).filter((p) => p !== permissionToRemove);
|
||||
|
||||
const updateData: UpdateRolePermissionsData = {
|
||||
roleId,
|
||||
permissions: desired,
|
||||
updates: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const updatedRole = await this.roleService.update(updateData);
|
||||
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
|
||||
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid permissions provided")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
if (error.message.includes("Cannot update default roles")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeRolePermissions(teamId: number, roleId: string, permissionsToRemove: PermissionString[]) {
|
||||
if (permissionsToRemove && permissionsToRemove.length > 0) {
|
||||
for (const permission of permissionsToRemove) {
|
||||
if (!isValidPermissionString(permission)) {
|
||||
throw new BadRequestException(
|
||||
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const current = await this.getRolePermissions(teamId, roleId);
|
||||
const toRemove = new Set(permissionsToRemove || []);
|
||||
const desired = (current || []).filter((p) => !toRemove.has(p));
|
||||
|
||||
const updateData: UpdateRolePermissionsData = {
|
||||
roleId,
|
||||
permissions: desired,
|
||||
updates: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const updatedRole = await this.roleService.update(updateData);
|
||||
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
|
||||
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid permissions provided")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
if (error.message.includes("Cannot update default roles")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setRolePermissions(teamId: number, roleId: string, permissions: PermissionString[]) {
|
||||
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const updateData: UpdateRolePermissionsData = {
|
||||
roleId,
|
||||
permissions,
|
||||
updates: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const updatedRole = await this.roleService.update(updateData);
|
||||
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
|
||||
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid permissions provided")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
if (error.message.includes("Cannot update default roles")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
import { TeamRolesOutputService } from "../organizations/teams/roles/services/team-roles-output.service";
|
||||
import { RolesPermissionsCacheService } from "./permissions/services/roles-permissions-cache.service";
|
||||
import { RolesPermissionsOutputService } from "./permissions/services/roles-permissions-output.service";
|
||||
import { RolesPermissionsService } from "./permissions/services/roles-permissions.service";
|
||||
import { RolesService } from "./services/roles.service";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: RoleService,
|
||||
useFactory: () => new RoleService(),
|
||||
},
|
||||
RolesService,
|
||||
TeamRolesOutputService,
|
||||
OrganizationsRolesOutputService,
|
||||
RolesPermissionsService,
|
||||
RolesPermissionsOutputService,
|
||||
RolesPermissionsCacheService,
|
||||
],
|
||||
exports: [
|
||||
RolesService,
|
||||
TeamRolesOutputService,
|
||||
OrganizationsRolesOutputService,
|
||||
RolesPermissionsService,
|
||||
RolesPermissionsOutputService,
|
||||
RolesPermissionsCacheService,
|
||||
],
|
||||
})
|
||||
export class RolesModule {}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
|
||||
import { RolesPermissionsCacheService } from "@/modules/roles/permissions/services/roles-permissions-cache.service";
|
||||
import { BadRequestException, Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
|
||||
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||
import type { CreateRoleData, UpdateRolePermissionsData } from "@calcom/platform-libraries/pbac";
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
private readonly logger = new Logger(RolesService.name);
|
||||
constructor(
|
||||
private readonly rolesService: RoleService,
|
||||
private readonly rolesPermissionsCacheService: RolesPermissionsCacheService
|
||||
) {}
|
||||
|
||||
async createRole(teamId: number, data: CreateTeamRoleInput) {
|
||||
const createRoleData: CreateRoleData = {
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
description: data.description,
|
||||
permissions: data.permissions || [],
|
||||
teamId: teamId,
|
||||
type: "CUSTOM",
|
||||
};
|
||||
|
||||
try {
|
||||
const role = await this.rolesService.createRole(createRoleData);
|
||||
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
|
||||
return role;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`RolesService - createRole failed (teamId=${teamId}, roleName=${data.name}): ${
|
||||
error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
||||
}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("already exists")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
// Map permission validation failures from PBAC service
|
||||
if (error.message.toLowerCase().includes("permission")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getRole(teamId: number, roleId: string) {
|
||||
const role = await this.rolesService.getRole(roleId);
|
||||
|
||||
if (!role || role.teamId !== teamId) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async getTeamRoles(teamId: number, skip = 0, take = 250) {
|
||||
const allRoles = await this.rolesService.getTeamRoles(teamId);
|
||||
|
||||
const paginatedRoles = allRoles.slice(skip, skip + take);
|
||||
|
||||
return paginatedRoles;
|
||||
}
|
||||
|
||||
async updateRole(teamId: number, roleId: string, data: UpdateTeamRoleInput) {
|
||||
const belongsToTeam = await this.rolesService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const updateData: UpdateRolePermissionsData = {
|
||||
roleId,
|
||||
permissions: data.permissions,
|
||||
updates: {
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const role = await this.rolesService.update(updateData);
|
||||
|
||||
if (data.permissions) {
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
}
|
||||
|
||||
return role;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Role not found")) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
if (error.message.includes("Cannot update default roles")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
if (
|
||||
error.message.includes("Invalid permissions provided") ||
|
||||
error.message.toLowerCase().includes("permission")
|
||||
) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRole(teamId: number, roleId: string) {
|
||||
const belongsToTeam = await this.rolesService.roleBelongsToTeam(roleId, teamId);
|
||||
if (!belongsToTeam) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
const role = await this.rolesService.getRole(roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.rolesService.deleteRole(roleId);
|
||||
|
||||
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
|
||||
|
||||
return role;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Cannot delete default roles")) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
|
||||
if (error.message.includes("Role not found")) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class FeaturesRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
async create(data: Prisma.FeatureCreateInput) {
|
||||
// note(Lauris): upserting because this create function is called in multiple tests in parallel and otherwise would lead to unique
|
||||
// key constraint violation.
|
||||
return await this.prismaWriteClient.feature.upsert({
|
||||
where: { slug: data.slug },
|
||||
create: data,
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamFeature(data: Prisma.TeamFeaturesCreateInput) {
|
||||
return await this.prismaWriteClient.teamFeatures.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async enableFeatureForTeam(teamId: number, featureId: string, assignedBy = "test") {
|
||||
await this.prismaWriteClient.teamFeatures.upsert({
|
||||
where: {
|
||||
teamId_featureId: {
|
||||
teamId,
|
||||
featureId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
featureId,
|
||||
assignedBy,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async disableFeatureForTeam(teamId: number, featureSlug: string) {
|
||||
return await this.prismaWriteClient.teamFeatures.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
featureId: featureSlug,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBySlug(slug: string) {
|
||||
return await this.prismaWriteClient.feature.delete({
|
||||
where: { slug },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTeamFeature(teamId: number, featureSlug: string) {
|
||||
return await this.prismaWriteClient.teamFeatures.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
featureId: featureSlug,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,20 @@ export class OrganizationRepositoryFixture {
|
||||
});
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
teamId: Team["id"],
|
||||
settings: {
|
||||
orgAutoAcceptEmail?: string;
|
||||
isOrganizationVerified?: boolean;
|
||||
isOrganizationConfigured?: boolean;
|
||||
}
|
||||
) {
|
||||
return this.prismaWriteClient.organizationSettings.update({
|
||||
where: { organizationId: teamId },
|
||||
data: settings,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(teamId: Team["id"]) {
|
||||
return await this.prismaWriteClient.$transaction(async (prisma) => {
|
||||
await prisma.organizationSettings.delete({
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
|
||||
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
|
||||
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
|
||||
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"]
|
||||
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"],
|
||||
"@calcom/platform-libraries/organizations": ["../../../packages/platform/libraries/organizations.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -22,6 +22,7 @@ type IconName =
|
||||
| "binary"
|
||||
| "blocks"
|
||||
| "bold"
|
||||
| "book"
|
||||
| "book-open-check"
|
||||
| "book-open"
|
||||
| "book-user"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData";
|
||||
@@ -61,8 +61,9 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
};
|
||||
|
||||
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
const organizationRepository = getOrganizationRepository();
|
||||
const isOrgPrivate = organizationId
|
||||
? await OrganizationRepository.checkIfPrivate({
|
||||
? await organizationRepository.checkIfPrivate({
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
|
||||
@@ -26,11 +26,9 @@ const ServerPage = async () => {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Hello {username} || there. Not sure how to do this nicely with i18n just yet
|
||||
const userName = session.user.name || "there";
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <OnboardingView userName={userName} userEmail={userEmail} />;
|
||||
return <OnboardingView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view";
|
||||
import { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
@@ -28,7 +28,7 @@ const ServerPage = async () => {
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <PersonalProfileView userEmail={userEmail} />;
|
||||
return <PersonalSettingsView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { PersonalVideoView } from "~/onboarding/personal/video/personal-video-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("connect_video")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/personal/video"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <PersonalVideoView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("team_brand")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/brand"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamBrandView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
+5
-3
@@ -4,12 +4,13 @@ import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
const orgIdSchema = z.object({ id: z.coerce.number() });
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
const organizationRepository = getOrganizationRepository();
|
||||
const input = orgIdSchema.safeParse(await params);
|
||||
if (!input.success) {
|
||||
return await _generateMetadata(
|
||||
@@ -21,7 +22,7 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
|
||||
const org = await organizationRepository.adminFindById({ id: input.data.id });
|
||||
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("editing_org")}: ${org.name}`,
|
||||
@@ -33,11 +34,12 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
};
|
||||
|
||||
const Page = async ({ params }: { params: Params }) => {
|
||||
const organizationRepository = getOrganizationRepository();
|
||||
const input = orgIdSchema.safeParse(await params);
|
||||
|
||||
if (!input.success) throw new Error("Invalid access");
|
||||
|
||||
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
|
||||
const org = await organizationRepository.adminFindById({ id: input.data.id });
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader title={`${t("editing_org")}: ${org.name}`} description={t("admin_orgs_edit_description")}>
|
||||
|
||||
@@ -9,6 +9,10 @@ const LINKS = [
|
||||
title: "Bookings by Hour",
|
||||
href: "/settings/admin/playground/bookings-by-hour",
|
||||
},
|
||||
{
|
||||
title: "Weekly Calendar",
|
||||
href: "/settings/admin/playground/weekly-calendar",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import type { CalendarComponentProps } from "@calcom/features/calendars/weeklyview/types/state";
|
||||
|
||||
const makeDate = (dayOffset: number, hour: number, minute: number = 0) => {
|
||||
return dayjs("2025-01-06").add(dayOffset, "day").hour(hour).minute(minute).second(0).toDate();
|
||||
};
|
||||
|
||||
const getBaseProps = (events: CalendarEvent[]): CalendarComponentProps => ({
|
||||
startDate: dayjs("2025-01-06").toDate(), // Monday
|
||||
endDate: dayjs("2025-01-12").toDate(), // Sunday
|
||||
events,
|
||||
startHour: 6,
|
||||
endHour: 18,
|
||||
gridCellsPerHour: 4,
|
||||
timezone: "UTC",
|
||||
showBackgroundPattern: false,
|
||||
showBorder: false,
|
||||
hideHeader: true,
|
||||
borderColor: "subtle",
|
||||
});
|
||||
|
||||
type Scenario = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
expected: string;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
id: "two-overlapping",
|
||||
title: "Two Overlapping Events",
|
||||
description: "Two events with overlapping time ranges on the same day",
|
||||
expected:
|
||||
"Second event should be offset 8% to the right, both 80% width. Hover should bring event to front.",
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Meeting A",
|
||||
start: makeDate(0, 10, 0),
|
||||
end: makeDate(0, 11, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Meeting B",
|
||||
start: makeDate(0, 10, 30),
|
||||
end: makeDate(0, 11, 30),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "three-cascading",
|
||||
title: "Three Overlapping Events (Cascading)",
|
||||
description: "Three events that overlap, creating a cascading effect",
|
||||
expected:
|
||||
"Events should cascade with offsets 0%, 8%, 16%. Z-index should increment. Hover brings any to top.",
|
||||
events: [
|
||||
{
|
||||
id: 3,
|
||||
title: "Long Meeting",
|
||||
start: makeDate(1, 10, 0),
|
||||
end: makeDate(1, 12, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Mid Meeting",
|
||||
start: makeDate(1, 10, 30),
|
||||
end: makeDate(1, 11, 30),
|
||||
options: { status: "PENDING", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Late Meeting",
|
||||
start: makeDate(1, 11, 0),
|
||||
end: makeDate(1, 12, 30),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "non-overlapping",
|
||||
title: "Non-Overlapping Events",
|
||||
description: "Events that don't overlap should not cascade",
|
||||
expected: "Both events at 0% offset (separate groups), no cascade. Both should be 80% width.",
|
||||
events: [
|
||||
{
|
||||
id: 6,
|
||||
title: "Morning Meeting",
|
||||
start: makeDate(2, 10, 0),
|
||||
end: makeDate(2, 11, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Afternoon Meeting",
|
||||
start: makeDate(2, 12, 0),
|
||||
end: makeDate(2, 13, 0),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "same-start-time",
|
||||
title: "Same Start Time, Different Durations",
|
||||
description: "Multiple events starting at the same time with varying lengths",
|
||||
expected: "Longest event first (base of cascade), shorter ones offset 8%, 16%. All start at 10:00.",
|
||||
events: [
|
||||
{
|
||||
id: 8,
|
||||
title: "2-Hour Meeting",
|
||||
start: makeDate(3, 10, 0),
|
||||
end: makeDate(3, 12, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "1.5-Hour Meeting",
|
||||
start: makeDate(3, 10, 0),
|
||||
end: makeDate(3, 11, 30),
|
||||
options: { status: "ACCEPTED", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "30-Min Meeting",
|
||||
start: makeDate(3, 10, 0),
|
||||
end: makeDate(3, 10, 30),
|
||||
options: { status: "PENDING", color: "#10b981" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chain-overlaps",
|
||||
title: "Chain Overlaps (A→B→C)",
|
||||
description: "Events where A overlaps B, and B overlaps C",
|
||||
expected: "Single overlap group with cascading offsets 0%, 8%, 16%.",
|
||||
events: [
|
||||
{
|
||||
id: 11,
|
||||
title: "Event A",
|
||||
start: makeDate(4, 10, 0),
|
||||
end: makeDate(4, 11, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: "Event B",
|
||||
start: makeDate(4, 10, 30),
|
||||
end: makeDate(4, 11, 30),
|
||||
options: { status: "ACCEPTED", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: "Event C",
|
||||
start: makeDate(4, 11, 0),
|
||||
end: makeDate(4, 12, 0),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dense-day",
|
||||
title: "Dense Day (20+ Events)",
|
||||
description: "A very busy day with many overlapping events",
|
||||
expected:
|
||||
"Visually tight stack with multiple cascading levels. Right edge should not overflow. Hover should still work.",
|
||||
events: [
|
||||
{
|
||||
id: 14,
|
||||
title: "Team Standup",
|
||||
start: makeDate(5, 9, 0),
|
||||
end: makeDate(5, 9, 30),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
title: "Client Call",
|
||||
start: makeDate(5, 9, 15),
|
||||
end: makeDate(5, 10, 0),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
title: "Design Review",
|
||||
start: makeDate(5, 9, 45),
|
||||
end: makeDate(5, 11, 0),
|
||||
options: { status: "PENDING", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
title: "1-on-1",
|
||||
start: makeDate(5, 10, 0),
|
||||
end: makeDate(5, 10, 30),
|
||||
options: { status: "ACCEPTED", color: "#8b5cf6" },
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
title: "Planning",
|
||||
start: makeDate(5, 10, 15),
|
||||
end: makeDate(5, 11, 30),
|
||||
options: { status: "ACCEPTED", color: "#ec4899" },
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
title: "Code Review",
|
||||
start: makeDate(5, 11, 0),
|
||||
end: makeDate(5, 12, 0),
|
||||
options: { status: "ACCEPTED", color: "#14b8a6" },
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
title: "Lunch Meeting",
|
||||
start: makeDate(5, 11, 30),
|
||||
end: makeDate(5, 12, 30),
|
||||
options: { status: "ACCEPTED", color: "#f97316" },
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
title: "Workshop",
|
||||
start: makeDate(5, 12, 0),
|
||||
end: makeDate(5, 13, 30),
|
||||
options: { status: "ACCEPTED", color: "#06b6d4" },
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
title: "Interview",
|
||||
start: makeDate(5, 12, 30),
|
||||
end: makeDate(5, 13, 0),
|
||||
options: { status: "PENDING", color: "#84cc16" },
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
title: "Retrospective",
|
||||
start: makeDate(5, 13, 0),
|
||||
end: makeDate(5, 14, 0),
|
||||
options: { status: "ACCEPTED", color: "#a855f7" },
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
title: "Demo",
|
||||
start: makeDate(5, 13, 15),
|
||||
end: makeDate(5, 14, 0),
|
||||
options: { status: "CANCELLED", color: "#ef4444" },
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
title: "Quick Sync",
|
||||
start: makeDate(5, 9, 10),
|
||||
end: makeDate(5, 9, 40),
|
||||
options: { status: "ACCEPTED", color: "#6366f1" },
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
title: "Architecture Discussion",
|
||||
start: makeDate(5, 9, 30),
|
||||
end: makeDate(5, 10, 30),
|
||||
options: { status: "ACCEPTED", color: "#ec4899" },
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
title: "Sprint Planning",
|
||||
start: makeDate(5, 10, 10),
|
||||
end: makeDate(5, 11, 0),
|
||||
options: { status: "ACCEPTED", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 33,
|
||||
title: "Product Demo",
|
||||
start: makeDate(5, 10, 45),
|
||||
end: makeDate(5, 11, 45),
|
||||
options: { status: "PENDING", color: "#14b8a6" },
|
||||
},
|
||||
{
|
||||
id: 34,
|
||||
title: "Bug Triage",
|
||||
start: makeDate(5, 11, 15),
|
||||
end: makeDate(5, 12, 15),
|
||||
options: { status: "ACCEPTED", color: "#8b5cf6" },
|
||||
},
|
||||
{
|
||||
id: 35,
|
||||
title: "Customer Feedback",
|
||||
start: makeDate(5, 11, 45),
|
||||
end: makeDate(5, 12, 45),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
{
|
||||
id: 36,
|
||||
title: "Team Sync",
|
||||
start: makeDate(5, 12, 15),
|
||||
end: makeDate(5, 13, 15),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
title: "Tech Talk",
|
||||
start: makeDate(5, 12, 45),
|
||||
end: makeDate(5, 13, 45),
|
||||
options: { status: "ACCEPTED", color: "#f97316" },
|
||||
},
|
||||
{
|
||||
id: 38,
|
||||
title: "Standup Follow-up",
|
||||
start: makeDate(5, 13, 30),
|
||||
end: makeDate(5, 14, 0),
|
||||
options: { status: "ACCEPTED", color: "#06b6d4" },
|
||||
},
|
||||
{
|
||||
id: 39,
|
||||
title: "Office Hours",
|
||||
start: makeDate(5, 13, 45),
|
||||
end: makeDate(5, 14, 30),
|
||||
options: { status: "ACCEPTED", color: "#a855f7" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "touching-events",
|
||||
title: "Touching Events (Edge Case)",
|
||||
description: "Events that touch exactly at boundaries",
|
||||
expected: "Separate groups; no cascade; both at 0% offset. Events touching at 11:00 should not overlap.",
|
||||
events: [
|
||||
{
|
||||
id: 25,
|
||||
title: "Morning Block",
|
||||
start: makeDate(6, 10, 0),
|
||||
end: makeDate(6, 11, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 26,
|
||||
title: "Afternoon Block",
|
||||
start: makeDate(6, 11, 0),
|
||||
end: makeDate(6, 12, 0),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mixed-statuses",
|
||||
title: "Mixed Event Statuses",
|
||||
description: "Events with different booking statuses",
|
||||
expected:
|
||||
"Visual styling should differ by status (ACCEPTED, PENDING, CANCELLED). Cascade should still work.",
|
||||
events: [
|
||||
{
|
||||
id: 27,
|
||||
title: "Confirmed Meeting",
|
||||
start: makeDate(0, 14, 0),
|
||||
end: makeDate(0, 15, 0),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 28,
|
||||
title: "Pending Approval",
|
||||
start: makeDate(0, 14, 30),
|
||||
end: makeDate(0, 15, 30),
|
||||
options: { status: "PENDING", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 29,
|
||||
title: "Cancelled Meeting",
|
||||
start: makeDate(0, 15, 0),
|
||||
end: makeDate(0, 16, 0),
|
||||
options: { status: "CANCELLED", color: "#ef4444" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "event-durations",
|
||||
title: "Event Duration Layout Tests",
|
||||
description: "Events with different durations to test layout logic (eventDuration > 30 changes flex-col)",
|
||||
expected:
|
||||
"Events ≤30min show horizontal layout (title and time inline). Events >30min show vertical layout (title and time stacked).",
|
||||
events: [
|
||||
{
|
||||
id: 40,
|
||||
title: "3min Quick Chat",
|
||||
start: makeDate(0, 9, 0),
|
||||
end: makeDate(0, 9, 3),
|
||||
options: { status: "ACCEPTED", color: "#3b82f6" },
|
||||
},
|
||||
{
|
||||
id: 41,
|
||||
title: "7min Standup",
|
||||
start: makeDate(0, 9, 15),
|
||||
end: makeDate(0, 9, 22),
|
||||
options: { status: "ACCEPTED", color: "#10b981" },
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
title: "15min Check-in",
|
||||
start: makeDate(0, 9, 30),
|
||||
end: makeDate(0, 9, 45),
|
||||
options: { status: "PENDING", color: "#f59e0b" },
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
title: "20min Review",
|
||||
start: makeDate(0, 10, 0),
|
||||
end: makeDate(0, 10, 20),
|
||||
options: { status: "ACCEPTED", color: "#8b5cf6" },
|
||||
},
|
||||
{
|
||||
id: 44,
|
||||
title: "30min Discussion",
|
||||
start: makeDate(0, 10, 30),
|
||||
end: makeDate(0, 11, 0),
|
||||
options: { status: "ACCEPTED", color: "#ec4899" },
|
||||
},
|
||||
{
|
||||
id: 45,
|
||||
title: "32min Discussion",
|
||||
start: makeDate(0, 11, 10),
|
||||
end: makeDate(0, 11, 42),
|
||||
options: { status: "ACCEPTED", color: "#ec4899" },
|
||||
},
|
||||
{
|
||||
id: 46,
|
||||
title: "40min Discussion",
|
||||
start: makeDate(0, 12, 0),
|
||||
end: makeDate(0, 12, 40),
|
||||
options: { status: "ACCEPTED", color: "#ec4899" },
|
||||
},
|
||||
{
|
||||
id: 47,
|
||||
title: "53min Workshop",
|
||||
description: "Learn about the latest trends in web development",
|
||||
start: makeDate(0, 13, 0),
|
||||
end: makeDate(0, 13, 53),
|
||||
options: { status: "ACCEPTED", color: "#14b8a6" },
|
||||
},
|
||||
{
|
||||
id: 48,
|
||||
title: "90min Workshop",
|
||||
description: "Learn about the latest trends in web development",
|
||||
start: makeDate(0, 14, 0),
|
||||
end: makeDate(0, 15, 30),
|
||||
options: { status: "ACCEPTED", color: "#14b8a6" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ScenarioCard({ scenario }: { scenario: Scenario }) {
|
||||
const [showData, setShowData] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-subtle rounded-lg border p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">{scenario.title}</h3>
|
||||
<p className="text-subtle mt-1 text-sm">{scenario.description}</p>
|
||||
<div className="bg-subtle mt-2 rounded p-2 text-xs">
|
||||
<strong>Expected:</strong> {scenario.expected}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] overflow-hidden rounded border">
|
||||
<Calendar {...getBaseProps(scenario.events)} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowData(!showData)}
|
||||
className="text-emphasis mt-2 text-sm underline hover:no-underline">
|
||||
{showData ? "Hide" : "Show"} Event Data
|
||||
</button>
|
||||
|
||||
{showData && (
|
||||
<pre className="bg-subtle mt-2 overflow-auto rounded p-2 text-xs">
|
||||
{JSON.stringify(scenario.events, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WeeklyCalendarPlayground() {
|
||||
return (
|
||||
<div className="space-y-8 p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Weekly Calendar Playground</h1>
|
||||
<p className="text-subtle mt-2">
|
||||
Test the overlapping events cascading layout with various scenarios. Events should cascade with 80%
|
||||
width and 8% left offset per overlap level. Hovering an event should bring it to the front.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grid View of All Scenarios */}
|
||||
<div className="space-y-8">
|
||||
<h2 className="mb-4 text-2xl font-bold">All Scenarios (Side-by-Side)</h2>
|
||||
{scenarios.map((scenario, index) => (
|
||||
<ScenarioCard key={index} scenario={scenario} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Testing Checklist */}
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">Testing Checklist</h2>
|
||||
<ul className="text-subtle space-y-2 text-sm">
|
||||
<li>✓ Visual appearance matches expectations (80% width with 8% cascading offsets)</li>
|
||||
<li>✓ Hover behavior works smoothly - hovered event appears topmost</li>
|
||||
<li>✓ No visual glitches with 3+ overlapping events</li>
|
||||
<li>✓ Performance is acceptable with dense event days (10+ events)</li>
|
||||
<li>✓ Edge case: Events with identical start times render correctly</li>
|
||||
<li>✓ Edge case: Events that touch exactly (end = next start) don't incorrectly overlap</li>
|
||||
<li>✓ Different booking statuses (ACCEPTED, PENDING, CANCELLED) display correctly</li>
|
||||
<li>✓ Color bars on the left side of events display correctly</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
|
||||
import OutOfOfficeView from "~/settings/my-account/out-of-office-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -12,7 +12,7 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = () => {
|
||||
return <OutOfOfficeEntriesList />;
|
||||
return <OutOfOfficeView />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+6
-5
@@ -2,7 +2,7 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { validateUserHasOrg } from "../../../actions/validateUserHasOrg";
|
||||
@@ -24,11 +24,12 @@ const Page = async () => {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
const organizationId = session?.user?.org?.id;
|
||||
const organizationRepository = getOrganizationRepository();
|
||||
const otherTeams = organizationId
|
||||
? await OrganizationRepository.findTeamsInOrgIamNotPartOf({
|
||||
userId: session?.user.id,
|
||||
parentId: organizationId,
|
||||
})
|
||||
? await organizationRepository.findTeamsInOrgIamNotPartOf({
|
||||
userId: session?.user.id,
|
||||
parentId: organizationId,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -130,7 +130,7 @@ const Page = async ({ searchParams }: PageProps) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error("Failed to create Cal AI workflow:", error);
|
||||
console.error("Failed to create Cal.ai workflow:", error);
|
||||
redirect("/workflows?error=failed-to-create-workflow");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload: OAuthTokenPayload = {
|
||||
const payloadAuthToken: OAuthTokenPayload = {
|
||||
userId: decodedRefreshToken.userId,
|
||||
teamId: decodedRefreshToken.teamId,
|
||||
scope: decodedRefreshToken.scope,
|
||||
@@ -58,11 +58,23 @@ async function handler(req: NextRequest) {
|
||||
clientId: client_id,
|
||||
};
|
||||
|
||||
const access_token = jwt.sign(payload, secretKey, {
|
||||
const payloadRefreshToken: OAuthTokenPayload = {
|
||||
userId: decodedRefreshToken.userId,
|
||||
teamId: decodedRefreshToken.teamId,
|
||||
scope: decodedRefreshToken.scope,
|
||||
token_type: "Refresh Token",
|
||||
clientId: client_id,
|
||||
};
|
||||
|
||||
const access_token = jwt.sign(payloadAuthToken, secretKey, {
|
||||
expiresIn: 1800, // 30 min
|
||||
});
|
||||
|
||||
return NextResponse.json({ access_token }, { status: 200 });
|
||||
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
|
||||
expiresIn: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
|
||||
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NextRequest } from "next/server";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
|
||||
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -17,13 +18,11 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const bookingData = bookingCancelWithCsrfSchema.parse(appDirRequestBody);
|
||||
const cookieStore = await cookies();
|
||||
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
|
||||
|
||||
if (!cookieToken || cookieToken !== bookingData.csrfToken) {
|
||||
return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 });
|
||||
const csrfError = await validateCsrfToken(bookingData.csrfToken);
|
||||
if (csrfError) {
|
||||
return csrfError;
|
||||
}
|
||||
cookieStore.delete("calcom.csrf_token");
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -20,6 +21,7 @@ const checkoutSessionMetadataSchema = z.object({
|
||||
teamName: z.string(),
|
||||
teamSlug: z.string(),
|
||||
userId: z.string().transform(Number),
|
||||
isOnboarding: z.string().optional(),
|
||||
});
|
||||
|
||||
const generateRandomString = () => {
|
||||
@@ -66,6 +68,7 @@ async function getHandler(req: NextRequest) {
|
||||
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
|
||||
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
|
||||
userId: checkoutSession.metadata.userId,
|
||||
isOnboarding: checkoutSession.metadata.isOnboarding,
|
||||
};
|
||||
|
||||
const team = await prisma.team.create({
|
||||
@@ -102,6 +105,16 @@ async function getHandler(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this is from onboarding flow and redirect accordingly
|
||||
const isOnboarding = checkoutSessionMetadata.isOnboarding === "true";
|
||||
|
||||
if (isOnboarding) {
|
||||
// Redirect to event-types for onboarding flow
|
||||
return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), {
|
||||
status: 302,
|
||||
});
|
||||
}
|
||||
|
||||
// redirect to team screen
|
||||
return NextResponse.redirect(
|
||||
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
generateGuestMeetingTokenFromOwnerMeetingToken,
|
||||
updateMeetingTokenIfExpired,
|
||||
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
||||
import { VideoCallGuestRepository } from "@calcom/features/video-call-guest/repositories/VideoCallGuestRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
|
||||
|
||||
const videoCallGuestWithCsrfSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
csrfToken: z.string().length(64, "Invalid CSRF token"),
|
||||
});
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
let appDirRequestBody;
|
||||
try {
|
||||
appDirRequestBody = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const guestData = videoCallGuestWithCsrfSchema.parse(appDirRequestBody);
|
||||
|
||||
const csrfError = await validateCsrfToken(guestData.csrfToken);
|
||||
if (csrfError) {
|
||||
return csrfError;
|
||||
}
|
||||
|
||||
const bookingRepo = new BookingRepository(prisma);
|
||||
const booking = await bookingRepo.findBookingIncludeCalVideoSettingsAndReferences({
|
||||
bookingUid: guestData.bookingUid,
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { hosts, guests } = getHostsAndGuests(booking);
|
||||
const isHost = hosts.some((host) => host.email.toLowerCase() === guestData.email.trim().toLowerCase());
|
||||
const isGuest = guests.some((guest) => guest.email.toLowerCase() === guestData.email.trim().toLowerCase());
|
||||
|
||||
if (isHost) {
|
||||
return NextResponse.json({ error: "hosts_must_use_login" }, { status: 403 });
|
||||
} else if (!isGuest) {
|
||||
return NextResponse.json({ error: "invalid_guest_email" }, { status: 403 });
|
||||
}
|
||||
|
||||
const videoCallGuestRepo = new VideoCallGuestRepository(prisma);
|
||||
const guestSession = await videoCallGuestRepo.upsertVideoCallGuest({
|
||||
bookingUid: guestData.bookingUid,
|
||||
email: guestData.email,
|
||||
name: guestData.name,
|
||||
});
|
||||
|
||||
const videoReference = getCalVideoReference(booking.references);
|
||||
|
||||
if (!videoReference) {
|
||||
return NextResponse.json({ error: "Video reference not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const endTime = new Date(booking.endTime);
|
||||
const fourteenDaysAfter = new Date(endTime.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||
const epochTimeFourteenDaysAfter = Math.floor(fourteenDaysAfter.getTime() / 1000);
|
||||
|
||||
const videoReferencePassword = await updateMeetingTokenIfExpired({
|
||||
bookingReferenceId: videoReference.id,
|
||||
roomName: videoReference.uid,
|
||||
meetingToken: videoReference.meetingPassword,
|
||||
exp: epochTimeFourteenDaysAfter,
|
||||
});
|
||||
|
||||
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
|
||||
meetingToken: videoReferencePassword,
|
||||
userId: guestSession.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
guestSessionId: guestSession.id,
|
||||
meetingPassword: guestMeetingPassword,
|
||||
meetingUrl: videoReference.meetingUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -3,6 +3,7 @@ import { Retell } from "retell-sdk";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client";
|
||||
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
@@ -71,6 +72,8 @@ vi.mock("retell-sdk", () => ({
|
||||
|
||||
const mockHasAvailableCredits = vi.fn();
|
||||
const mockChargeCredits = vi.fn();
|
||||
const mockSendCreditBalanceLimitReachedEmails = vi.fn();
|
||||
const mockSendCreditBalanceLowWarningEmails = vi.fn();
|
||||
|
||||
vi.mock("@calcom/features/ee/billing/credit-service", () => ({
|
||||
CreditService: vi.fn().mockImplementation(() => ({
|
||||
@@ -79,6 +82,12 @@ vi.mock("@calcom/features/ee/billing/credit-service", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/emails/email-manager", () => ({
|
||||
sendCreditBalanceLimitReachedEmails: (...args: unknown[]) =>
|
||||
mockSendCreditBalanceLimitReachedEmails(...args),
|
||||
sendCreditBalanceLowWarningEmails: (...args: unknown[]) => mockSendCreditBalanceLowWarningEmails(...args),
|
||||
}));
|
||||
|
||||
const mockFindByPhoneNumber = vi.fn();
|
||||
const mockFindByProviderAgentId = vi.fn();
|
||||
|
||||
@@ -231,6 +240,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits
|
||||
callDuration: 120,
|
||||
externalRef: "retell:test-call-id",
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -288,6 +298,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits
|
||||
callDuration: 180,
|
||||
externalRef: "retell:test-call-id",
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -471,6 +482,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
credits: expectedCredits,
|
||||
callDuration: durationSeconds,
|
||||
externalRef: expect.stringMatching(/^retell:test-call-/),
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -517,7 +529,12 @@ describe("Retell AI Webhook Handler", () => {
|
||||
const response = await callPOST(createMockRequest(body, "valid-signature"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, credits: 61, callDuration: 125 }) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
|
||||
expect.objectContaining({
|
||||
userId: 42,
|
||||
credits: 61,
|
||||
callDuration: 125,
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
}) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -574,6 +591,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits
|
||||
callDuration: 60,
|
||||
externalRef: "retell:test-idempotency-call",
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -760,6 +778,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
credits: 4, // 7 seconds = 0.117 minutes * $0.29 = $0.034 = 4 credits (rounded up)
|
||||
callDuration: 7,
|
||||
externalRef: "retell:call_bcd94f5a50832873a5fd68cb1aa",
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -794,6 +813,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
teamId: 10,
|
||||
credits: 29, // 60 seconds = 1 minute * $0.29 = 29 credits
|
||||
callDuration: 60,
|
||||
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,6 +63,8 @@ const RetellWebhookSchema = z.object({
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
type RetellCallData = z.infer<typeof RetellWebhookSchema>["call"];
|
||||
|
||||
async function chargeCreditsForCall({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -120,7 +122,7 @@ async function chargeCreditsForCall({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCallAnalyzed(callData: any) {
|
||||
async function handleCallAnalyzed(callData: RetellCallData) {
|
||||
const { from_number, call_id, call_cost, call_type, agent_id } = callData;
|
||||
|
||||
if (
|
||||
@@ -165,7 +167,7 @@ async function handleCallAnalyzed(callData: any) {
|
||||
}
|
||||
|
||||
userId = agent.userId ?? undefined;
|
||||
teamId = agent.teamId ?? undefined;
|
||||
teamId = agent.team?.parentId ?? agent.teamId ?? undefined;
|
||||
|
||||
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
|
||||
} else {
|
||||
@@ -181,7 +183,7 @@ async function handleCallAnalyzed(callData: any) {
|
||||
}
|
||||
|
||||
userId = phoneNumber.userId ?? undefined;
|
||||
teamId = phoneNumber.teamId ?? undefined;
|
||||
teamId = phoneNumber.team?.parentId ?? phoneNumber.teamId ?? undefined;
|
||||
|
||||
log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`);
|
||||
}
|
||||
|
||||
+12
-9
@@ -125,16 +125,19 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
}
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
|
||||
opacity: 0,
|
||||
}
|
||||
: {
|
||||
visibility: "visible",
|
||||
}
|
||||
visibility: "visible",
|
||||
opacity: 1,
|
||||
}
|
||||
}>
|
||||
<IconSprites />
|
||||
<SpeculationRules
|
||||
|
||||
@@ -115,8 +115,8 @@ export const AppPage = ({
|
||||
|
||||
const handleAppInstall = () => {
|
||||
if (isRedirectApp(slug)) {
|
||||
setIsLoading(true);
|
||||
mutation.mutate({ type, variant, slug });
|
||||
// For redirect apps, open the external URL directly
|
||||
if (website) window.open(website, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -10,7 +10,6 @@ export const AppSetupMap = {
|
||||
"exchange2016-calendar": dynamic(() => import("@calcom/web/components/apps/exchange2016calendar/Setup")),
|
||||
"caldav-calendar": dynamic(() => import("@calcom/web/components/apps/caldavcalendar/Setup")),
|
||||
"ics-feed": dynamic(() => import("@calcom/web/components/apps/ics-feedcalendar/Setup")),
|
||||
zapier: dynamic(() => import("@calcom/web/components/apps/zapier/Setup")),
|
||||
make: dynamic(() => import("@calcom/web/components/apps/make/Setup")),
|
||||
sendgrid: dynamic(() => import("@calcom/web/components/apps/sendgrid/Setup")),
|
||||
stripe: dynamic(() => import("@calcom/web/components/apps/stripepayment/Setup")),
|
||||
|
||||
@@ -90,7 +90,7 @@ export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
|
||||
<p className="text-sm">Click or scan the invoice below to pay</p>
|
||||
<Link
|
||||
href={`lightning:${paymentRequest}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-white p-2 font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-transparent bg-white p-2 font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
<QRCode size={192} value={paymentRequest} />
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
export interface IZapierSetupProps {
|
||||
inviteLink?: string;
|
||||
}
|
||||
|
||||
const ZAPIER = "zapier";
|
||||
|
||||
export default function ZapierSetup(props: IZapierSetupProps) {
|
||||
const [newApiKeys, setNewApiKeys] = useState<Record<string, string>>({});
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "automation" });
|
||||
const oldApiKey = trpc.viewer.apiKeys.findKeyOfType.useQuery({ appId: ZAPIER });
|
||||
const teamsList = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const teams = teamsList.data?.map((team) => ({ id: team.id, name: team.name }));
|
||||
const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.apiKeys.findKeyOfType.invalidate();
|
||||
},
|
||||
});
|
||||
const zapierCredentials: { userCredentialIds: number[] } | undefined = integrations.data?.items.find(
|
||||
(item: { type: string }) => item.type === "zapier_automation"
|
||||
);
|
||||
const [credentialId] = zapierCredentials?.userCredentialIds || [false];
|
||||
const showContent = integrations.data && integrations.isSuccess && credentialId;
|
||||
const isCalDev = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.dev";
|
||||
|
||||
async function createApiKey(teamId?: number) {
|
||||
const event = { note: "Zapier", expiresAt: null, appId: ZAPIER, teamId };
|
||||
const apiKey = await utils.client.viewer.apiKeys.create.mutate(event);
|
||||
|
||||
if (oldApiKey.data) {
|
||||
const oldKey = teamId
|
||||
? oldApiKey.data.find((key) => key.teamId === teamId)
|
||||
: oldApiKey.data.find((key) => !key.teamId);
|
||||
|
||||
if (oldKey) {
|
||||
deleteApiKey.mutate({
|
||||
id: oldKey.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function generateApiKey(teamId?: number) {
|
||||
const apiKey = await createApiKey(teamId);
|
||||
setNewApiKeys({ ...newApiKeys, [teamId || ""]: apiKey });
|
||||
}
|
||||
|
||||
if (integrations.isPending) {
|
||||
return <div className="bg-emphasis absolute z-50 flex h-screen w-full items-center" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-emphasis flex h-screen">
|
||||
{showContent ? (
|
||||
<div className="bg-default m-auto max-w-[43em] overflow-auto rounded pb-10 md:p-10">
|
||||
<div className="md:flex md:flex-row">
|
||||
<div className="invisible md:visible">
|
||||
<img className="h-11" src="/api/app-store/zapier/icon.svg" alt="Zapier Logo" />
|
||||
</div>
|
||||
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
|
||||
<div className="text-default">{t("setting_up_zapier")}</div>
|
||||
|
||||
<>
|
||||
<div className="mt-1 text-xl">{t("generate_api_key")}:</div>
|
||||
{!teams ? (
|
||||
<Button color="secondary" onClick={() => createApiKey()} className="mb-4 mt-2">
|
||||
{t("generate_api_key")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-8 text-sm font-semibold">Your event types:</div>
|
||||
{!newApiKeys[""] ? (
|
||||
<Button color="secondary" onClick={() => generateApiKey()} className="mb-4 mt-2">
|
||||
{t("generate_api_key")}
|
||||
</Button>
|
||||
) : (
|
||||
<CopyApiKey apiKey={newApiKeys[""]} />
|
||||
)}
|
||||
{teams.map((team) => {
|
||||
return (
|
||||
<div key={team.name}>
|
||||
<div className="mt-2 text-sm font-semibold">{team.name}:</div>
|
||||
{!newApiKeys[team.id] ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => generateApiKey(team.id)}
|
||||
className="mb-4 mt-2">
|
||||
{t("generate_api_key")}
|
||||
</Button>
|
||||
) : (
|
||||
<CopyApiKey apiKey={newApiKeys[team.id]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
<ol className="mb-5 ml-5 mt-5 list-decimal ltr:mr-5 rtl:ml-5">
|
||||
{isCalDev && (
|
||||
<li>
|
||||
{t("go_to")}
|
||||
<a href={props.inviteLink} className="text-orange-600 underline">
|
||||
{t("zapier_invite_link")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey="zapier_setup_instructions"
|
||||
components={[
|
||||
<li key="instruction_1">Log into your Zapier account and create a new Zap.</li>,
|
||||
<li key="instruction_2">
|
||||
Select Cal.com as your Trigger app. Also choose a Trigger event.
|
||||
</li>,
|
||||
<li key="instruction_3">Choose your account and then enter your Unique API Key.</li>,
|
||||
<li key="instruction_4">Test your Trigger.</li>,
|
||||
<li key="instruction_5">You're set!</li>,
|
||||
]}
|
||||
/>
|
||||
</ol>
|
||||
<Link href="/apps/installed/automation?hl=zapier" passHref={true} legacyBehavior>
|
||||
<Button color="secondary">{t("done")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AppNotInstalledMessage appName="zapier" />
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CopyApiKey = ({ apiKey }: { apiKey: string }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<div className="my-2 mt-3 flex-wrap sm:flex sm:flex-nowrap">
|
||||
<code className="bg-subtle h-full w-full whitespace-pre-wrap rounded-md py-[6px] pl-2 pr-2 sm:rounded-r-none sm:pr-5">
|
||||
{apiKey}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
showToast(t("api_key_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="mt-4 text-base sm:mt-0 sm:rounded-l-none">
|
||||
<Icon name="clipboard" className="h-4 w-4 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-subtle mb-5 mt-2 text-sm">{t("copy_somewhere_safe")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowEditActions,
|
||||
type BookingActionContext,
|
||||
} from "./bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
interface BookingActionsDropdownProps {
|
||||
booking: BookingItemProps;
|
||||
}
|
||||
|
||||
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Use store for all dialog states
|
||||
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
|
||||
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
|
||||
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
|
||||
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
|
||||
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
|
||||
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
|
||||
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.viewRecordingsDialogIsOpen
|
||||
);
|
||||
const setViewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.setViewRecordingsDialogIsOpen
|
||||
);
|
||||
const meetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.meetingSessionDetailsDialogIsOpen
|
||||
);
|
||||
const setMeetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.setMeetingSessionDetailsDialogIsOpen
|
||||
);
|
||||
const isNoShowDialogOpen = useBookingActionsStoreContext((state) => state.isNoShowDialogOpen);
|
||||
const setIsNoShowDialogOpen = useBookingActionsStoreContext((state) => state.setIsNoShowDialogOpen);
|
||||
const isOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.isOpenRescheduleDialog);
|
||||
const setIsOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.setIsOpenRescheduleDialog);
|
||||
const isOpenReassignDialog = useBookingActionsStoreContext((state) => state.isOpenReassignDialog);
|
||||
const setIsOpenReassignDialog = useBookingActionsStoreContext((state) => state.setIsOpenReassignDialog);
|
||||
const isOpenSetLocationDialog = useBookingActionsStoreContext((state) => state.isOpenSetLocationDialog);
|
||||
const setIsOpenLocationDialog = useBookingActionsStoreContext((state) => state.setIsOpenLocationDialog);
|
||||
const isOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.isOpenAddGuestsDialog);
|
||||
const setIsOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.setIsOpenAddGuestsDialog);
|
||||
const isOpenReportDialog = useBookingActionsStoreContext((state) => state.isOpenReportDialog);
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
|
||||
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
|
||||
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
|
||||
BAD_REQUEST: e.message,
|
||||
};
|
||||
|
||||
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === "CANCELLED";
|
||||
const isConfirmed = booking.status === "ACCEPTED";
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
|
||||
const userEmail = booking.loggedInUser.userEmail;
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
const isAttendee = !!userSeat;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === "");
|
||||
|
||||
const isDisabledCancelling = booking.eventType.disableCancelling;
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
}: {
|
||||
newLocation: string;
|
||||
credentialId: number | null;
|
||||
}) => {
|
||||
try {
|
||||
await setLocationMutation.mutateAsync({
|
||||
bookingId: booking.id,
|
||||
newLocation,
|
||||
credentialId,
|
||||
});
|
||||
} catch {
|
||||
// Errors are shown through the mutation onError handler
|
||||
}
|
||||
};
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
booking,
|
||||
isUpcoming,
|
||||
isOngoing,
|
||||
isBookingInPast,
|
||||
isCancelled,
|
||||
isConfirmed,
|
||||
isRejected,
|
||||
isPending,
|
||||
isRescheduled,
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
isBookingFromRoutingForm,
|
||||
isDisabledCancelling,
|
||||
isDisabledRescheduling,
|
||||
isCalVideoLocation,
|
||||
showPendingPayment: false, // This will be calculated below
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
getSeatReferenceUid,
|
||||
t,
|
||||
} as BookingActionContext;
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
: action.id === "reroute"
|
||||
? () => setRerouteDialogIsOpen(true)
|
||||
: action.id === "change_location"
|
||||
? () => setIsOpenLocationDialog(true)
|
||||
: action.id === "add_members"
|
||||
? () => setIsOpenAddGuestsDialog(true)
|
||||
: action.id === "reassign"
|
||||
? () => setIsOpenReassignDialog(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const baseAfterEventActions = getAfterEventActions(actionContext);
|
||||
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "view_recordings"
|
||||
? () => setViewRecordingsDialogIsOpen(true)
|
||||
: action.id === "meeting_session_details"
|
||||
? () => setMeetingSessionDetailsDialogIsOpen(true)
|
||||
: action.id === "charge_card"
|
||||
? () => setChargeCardDialogIsOpen(true)
|
||||
: action.id === "no_show"
|
||||
? () => {
|
||||
if (attendeeList.length === 1) {
|
||||
const attendee = attendeeList[0];
|
||||
noShowMutation.mutate({
|
||||
bookingUid: booking.uid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsNoShowDialogOpen(true);
|
||||
}
|
||||
: undefined,
|
||||
disabled:
|
||||
action.disabled ||
|
||||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
...reportAction,
|
||||
onClick: () => setIsOpenReportDialog(true),
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
bookingUid,
|
||||
attendees,
|
||||
setIsOpen,
|
||||
isOpen,
|
||||
}: {
|
||||
bookingUid: string;
|
||||
attendees: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
phoneNumber: string | null;
|
||||
}>;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
isOpen: boolean;
|
||||
}) => {
|
||||
const [noShowAttendees, setNoShowAttendees] = useState<
|
||||
Array<{
|
||||
email: string;
|
||||
noShow: boolean;
|
||||
}>
|
||||
>(attendees.map((attendee) => ({ email: attendee.email, noShow: attendee.noShow })));
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
setIsOpen(false);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent title={t("mark_as_no_show")} enableOverflow>
|
||||
<div className="space-y-2">
|
||||
{attendees.map((attendee, index) => (
|
||||
<label key={attendee.email} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noShowAttendees[index]?.noShow || false}
|
||||
onChange={(e) => {
|
||||
const newNoShowAttendees = [...noShowAttendees];
|
||||
newNoShowAttendees[index] = {
|
||||
email: attendee.email,
|
||||
noShow: e.target.checked,
|
||||
};
|
||||
setNoShowAttendees(newNoShowAttendees);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{attendee.name} ({attendee.email})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
onClick={() => {
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: noShowAttendees,
|
||||
});
|
||||
}}>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const parsedBooking = {
|
||||
...booking,
|
||||
eventType: booking.eventType.id ? booking.eventType : null,
|
||||
};
|
||||
|
||||
// Render dialogs that might be triggered from BookingListItem even if dropdown is not shown
|
||||
const dialogs = (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
bookingFromRoutingForm={isBookingFromRoutingForm}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isBookingFromRoutingForm &&
|
||||
parsedBooking.eventType &&
|
||||
parsedBooking.eventType.id !== undefined &&
|
||||
parsedBooking.eventType.slug !== undefined &&
|
||||
parsedBooking.eventType.title !== undefined &&
|
||||
parsedBooking.routedFromRoutingFormReponse && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{
|
||||
...parsedBooking,
|
||||
metadata: parsedBooking.metadata as z.infer<typeof bookingMetadataSchema>,
|
||||
routedFromRoutingFormReponse: parsedBooking.routedFromRoutingFormReponse,
|
||||
eventType: {
|
||||
length: parsedBooking.eventType.length ?? 0,
|
||||
schedulingType: parsedBooking.eventType.schedulingType ?? null,
|
||||
title: parsedBooking.eventType.title,
|
||||
id: parsedBooking.eventType.id,
|
||||
slug: parsedBooking.eventType.slug,
|
||||
team: parsedBooking.eventType.team ?? null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Don't render dropdown if edit actions shouldn't be shown
|
||||
if (!shouldShowEditActions(actionContext)) {
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import type { StoreApi } from "zustand";
|
||||
|
||||
import { createBookingActionsStore, type BookingActionsStore } from "./store";
|
||||
|
||||
export const BookingActionsStoreContext = createContext<StoreApi<BookingActionsStore> | null>(null);
|
||||
|
||||
export interface BookingActionsStoreProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BookingActionsStoreProvider = ({ children }: BookingActionsStoreProviderProps) => {
|
||||
const storeRef = useRef<StoreApi<BookingActionsStore>>();
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createBookingActionsStore();
|
||||
}
|
||||
|
||||
return (
|
||||
<BookingActionsStoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</BookingActionsStoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBookingActionsStoreContext = <T,>(
|
||||
selector: (store: BookingActionsStore) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean
|
||||
): T => {
|
||||
const bookingActionsStoreContext = useContext(BookingActionsStoreContext);
|
||||
|
||||
if (!bookingActionsStoreContext) {
|
||||
throw new Error("useBookingActionsStoreContext must be used within BookingActionsStoreProvider");
|
||||
}
|
||||
|
||||
return useStore(bookingActionsStoreContext, selector, equalityFn);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -9,8 +10,6 @@ import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -20,7 +19,6 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import type { AssignmentReason } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -48,42 +46,18 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { BookingActionsDropdown } from "./BookingActionsDropdown";
|
||||
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getPendingActions,
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowPendingActions,
|
||||
shouldShowEditActions,
|
||||
shouldShowRecurringCancelAction,
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
getReportAction,
|
||||
} from "./bookingActions";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
export type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
isToday: boolean;
|
||||
};
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
|
||||
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
|
||||
@@ -131,11 +105,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
@@ -147,17 +116,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
// Invalidate and refetch the bookings query to update the UI
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
@@ -185,12 +143,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||
@@ -219,6 +171,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const isDisabledCancelling = booking.eventType.disableCancelling;
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
@@ -298,49 +251,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
|
||||
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
|
||||
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
|
||||
BAD_REQUEST: e.message,
|
||||
};
|
||||
|
||||
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
}: {
|
||||
newLocation: string;
|
||||
/**
|
||||
* It could be set for conferencing locations that support team level installations.
|
||||
*/
|
||||
credentialId: number | null;
|
||||
}) => {
|
||||
try {
|
||||
await setLocationMutation.mutateAsync({
|
||||
bookingId: booking.id,
|
||||
newLocation,
|
||||
credentialId,
|
||||
});
|
||||
} catch {
|
||||
// Errors are shown through the mutation onError handler
|
||||
}
|
||||
};
|
||||
|
||||
// Getting accepted recurring dates to show
|
||||
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
|
||||
@@ -360,58 +270,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === "");
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
: action.id === "reroute"
|
||||
? () => setRerouteDialogIsOpen(true)
|
||||
: action.id === "change_location"
|
||||
? () => setIsOpenLocationDialog(true)
|
||||
: action.id === "add_members"
|
||||
? () => setIsOpenAddGuestsDialog(true)
|
||||
: action.id === "reassign"
|
||||
? () => setIsOpenReassignDialog(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const baseAfterEventActions = getAfterEventActions(actionContext);
|
||||
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "view_recordings"
|
||||
? () => setViewRecordingsDialogIsOpen(true)
|
||||
: action.id === "meeting_session_details"
|
||||
? () => setMeetingSessionDetailsDialogIsOpen(true)
|
||||
: action.id === "charge_card"
|
||||
? () => setChargeCardDialogIsOpen(true)
|
||||
: action.id === "no_show"
|
||||
? () => {
|
||||
if (attendeeList.length === 1) {
|
||||
const attendee = attendeeList[0];
|
||||
noShowMutation.mutate({
|
||||
bookingUid: booking.uid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsNoShowDialogOpen(true);
|
||||
}
|
||||
: undefined,
|
||||
disabled:
|
||||
action.disabled ||
|
||||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
@@ -421,72 +282,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
bookingFromRoutingForm={isBookingFromRoutingForm}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
@@ -555,8 +350,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<img
|
||||
<Image
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
@@ -661,94 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
{shouldShowEditActions(actionContext) && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
)}
|
||||
<BookingActionsDropdown booking={booking} />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -760,7 +470,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className="h-8 w-8"
|
||||
className="min-h-[34px] min-w-[34px]"
|
||||
tooltip={reportActionWithHandler.label}
|
||||
/>
|
||||
</div>
|
||||
@@ -782,14 +492,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isBookingFromRoutingForm && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -903,7 +605,7 @@ const RecurringBookingsTooltip = ({
|
||||
|
||||
return (
|
||||
(booking.recurringInfo &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
booking.eventType?.recurringEvent?.freq != null &&
|
||||
(booking.listingStatus === "recurring" ||
|
||||
booking.listingStatus === "unconfirmed" ||
|
||||
booking.listingStatus === "cancelled") && (
|
||||
@@ -1174,79 +876,6 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
attendees,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
bookingUid: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [noShowAttendees, setNoShowAttendees] = useState(
|
||||
attendees.map((attendee) => ({
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
}))
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
const newValue = data.attendees[0];
|
||||
setNoShowAttendees((old) =>
|
||||
old.map((attendee) =>
|
||||
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
|
||||
)
|
||||
);
|
||||
showToast(t(data.message), "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")} enableOverflow>
|
||||
{noShowAttendees.map((attendee, index) => (
|
||||
<form
|
||||
key={attendee.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
}}>
|
||||
<div
|
||||
className={`bg-muted flex items-center justify-between px-4 py-2 ${
|
||||
index === 0 ? "rounded-t-md" : ""
|
||||
} ${index === noShowAttendees.length - 1 ? "rounded-b-md" : "border-subtle border-b"}`}>
|
||||
<span className="text-emphasis flex flex-col text-sm">
|
||||
{attendee.name}
|
||||
{attendee.email && <span className="text-muted">({attendee.email})</span>}
|
||||
</span>
|
||||
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
|
||||
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
<DialogFooter noSticky>
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
@@ -1369,4 +998,13 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItem;
|
||||
// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking
|
||||
const BookingListItemWithProvider = (props: BookingItemProps) => {
|
||||
return (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingListItem {...props} />
|
||||
</BookingActionsStoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItemWithProvider;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
|
||||
import type { BookingItemProps } from "./BookingListItem";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
export interface BookingActionContext {
|
||||
booking: BookingItemProps;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user