Compare commits

...
Author SHA1 Message Date
github-actions[bot] a48a1bd5d8 chore: version packages (#24062)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-27 14:12:00 +00:00
Kartik Labhshetwar 1acd0a14ba improve: improve mobile layout for API keys settings and dialog (#24114) 2025-09-27 12:14:52 +05:30
Lauris Skraucis 6c9ac7636b docs: v2 disabled input (#24071)
* docs: v2 disabled input

* Delete apps/api/v2/swagger/documentation.json
2025-09-26 18:23:21 +00:00
Amit Sharma d261a23dbc fix: Bunch of fixes and minor changes throughout the app (#23356)
* Bunch of fixes and minor changes throughout the app

* remove logs

* fix: e2e

* fix: e2e

* fix: more e2e and design

* revert insights upgrade change
2025-09-26 12:25:49 +00:00
sean-brydonandcoderabbitai[bot] 8b7947a172 fix: remove legacy logic into legacy service (#24095)
* fix: remove legacy logic into legacy service

* Update packages/features/pbac/services/role-management.factory.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-26 11:56:44 +00:00
Keith Williams 89789b1e3b chore: Update CODEOWNERS to use @calcom/API team for API changes (#24100) 2025-09-26 11:46:03 +00:00
Eunjae LeeandDevin AI 43dd24c480 refactor: migrate TeamEventTypeForm to use PBAC instead of isTeamAdminOrOwner (#24034)
* refactor: migrate TeamEventTypeForm to use PBAC instead of isTeamAdminOrOwner

- Replace isTeamAdminOrOwner prop with permissions.canCreateEventType
- Move permission checks to server-side using PermissionCheckService
- Use eventType.create permission string as specified in PBAC guide
- Update all parent components: CreateEventTypeDialog, event-types-view, CreateEventTypePlatformWrapper
- Follow existing PBAC patterns from event-types-listing-view.tsx
- Maintain backward compatibility with role-based fallback

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: implement proper server-side PBAC permission checks

- Add eventType.create permission checks to TRPC teams.get handler
- Add PBAC permission checks to platform /organizations/{orgId}/teams/me endpoint
- Update all three components to use server-side permission data instead of client-side async calls
- Add canCreateEventTypes property to platform team types
- Maintain backward compatibility with role-based fallbacks
- Remove unused imports and variables

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: update client components to use server-side PBAC permission data

- Update event-types-view.tsx to use team.canCreateEventTypes from server
- Update CreateEventTypeDialog.tsx to use team.canCreateEventTypes with fallback
- Update CreateEventTypePlatformWrapper.tsx to use team.canCreateEventTypes with fallback
- Remove hardcoded permission values and role-based checks
- Maintain backward compatibility with existing role-based logic as fallback

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* apply correct PBAC for event type creation

* revert unexpected changes

* clean up

* address feedback

* fix type error

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-26 13:40:27 +02:00
Udit Takkar ae6b1d402a feat: add create invite link endpoint (#24073)
* feat: add create invite link endpoint

* tests: add e2e test

* chore: feeback

* chore: feeback

* chore; udate summary

* chore; udate summary

* chore: deelte swagger
2025-09-26 10:24:44 +00:00
Rajiv SahalandVolnei Munhoz 3054ad33b2 feat: add docs for calendar view atom (#24049)
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-09-26 09:49:28 +00:00
a4dbb1a90b refactor: replace isTeamAdminOrOwner with PBAC team.listMembers permission (#24006)
* refactor: replace isTeamAdminOrOwner with PBAC team.listMembers permission

- Add canListMembers prop to BookingsProps interface
- Implement server-side permission check using PermissionCheckService
- Handle organization vs team context as specified
- Use ADMIN/OWNER fallback roles for backward compatibility
- Replace user?.isTeamAdminOrOwner check in bookings column filter
- Fix React Hook dependency arrays for ESLint compliance

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: wrap canListMembers in permissions object and simplify server logic

- Wrap canListMembers in permissions object for future extensibility
- Simplify server-side logic to only use getTeamIdsWithPermission
- Remove unused imports (prisma, MembershipRole)
- Address user feedback on PR #24006

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* docs: add comment explaining canListMembers UI logic

- Clarify that teamIdsWithPermission.length > 0 check is for UI purposes
- Actual accurate filtering happens server-side for filter values

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add e2e test for member filter visibility

- Verify that users with the MEMBER role cannot see the member filter
- Test creates team with ADMIN and MEMBER users
- Confirms UI correctly reflects PBAC permissions
- Address user feedback on PR #24006

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: correct e2e test team member creation pattern

- Replace invalid teamId property with hasTeam and teammates pattern
- Fix TypeScript error in booking-filters.e2e.ts
- Resolve CI type check failure

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix e2e test

* remove booking.read permission from member

* add guide

* update

* resource scope

* fix markdown

* update usage

* update guide

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-26 06:26:20 -03:00
689150d78a fix(ui/auth): align email focus ring with password/username (#23885)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-09-26 07:39:01 +00:00
Benny JooandMorgan fba096f23d refactor: use permission service for team delete trpc endpoint (#24077)
* refactor: use permission service for team delete trpc endpoint

* add safety check

* throw forbidden

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-09-26 08:09:23 +01:00
AnshumancanrockandAnik Dhabal Babu 7d5a3391f1 fix: hydration issue ( Step 3 is not visible for all analytics apps ) (#24089)
* booking cancellation reason overflow fix

* apps-prod-issue

* resolve Step 3 blank screen in app installation flow in production

* resolve resolve Step 3 blank screen in app installation flow in production

* Update step-view.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-26 07:07:24 +00:00
Rajiv Sahal dcef99bf49 fix: update org billing portal service to handle platform orgs (#24088) 2025-09-26 05:56:25 +00:00
Anshumancanrock 805fcc22a5 booking cancellation reason overflow fix (#24072) 2025-09-26 03:34:03 +00:00
Volnei Munhoz c5b7da8ffd disable tui (#24076) 2025-09-25 20:16:20 +00:00
Anik Dhabal Babu 97f1f7b130 chore: add content type to ics file (#24069)
* chore: add content type into ics file

* Update generateIcsFile.ts
2025-09-25 18:44:21 +00:00
Alex van Andel 0ac460fce3 chore: when performing read operations we should use Promise.all, not $transaction (#24084) 2025-09-25 17:56:10 +00:00
Alex van Andel 82dfca8272 chore: Add createdAt default to eventtype table and remove extension (#24075) 2025-09-25 17:32:11 +00:00
Eunjae LeeandDevin AI d1c5576d4f feat: add availability and ooo permissions to PBAC registry (#24081)
* feat: add availability and ooo permissions to PBAC registry

- Add Availability and OutOfOffice resources to Resource enum
- Add CRUD permissions for both resources with empty scope arrays
- Create migration to seed admin_role with all CRUD permissions
- Create migration to seed member_role with read-only permissions

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add i18n entries for availability and ooo permissions

- Add pbac_resource_availability and pbac_resource_out_of_office resource names
- Add description entries for all CRUD operations on both resources
- Follow existing PBAC i18n pattern for consistency

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-25 16:55:35 +00:00
Morgan 7090e3ed60 chore: remove atoms e2e for now (#24085)
* chore: remove atoms e2e for now

* fixup! chore: remove atoms e2e for now

* fixup! fixup! chore: remove atoms e2e for now
2025-09-25 17:12:10 +01:00
Volnei Munhoz 9926fd2e79 restore npm run all (#24080) 2025-09-25 14:48:02 +00:00
Benny Joo 7793cb20d1 Revert "perf: Replace isTeamMember util with an index DB call (#24066)" (#24078)
This reverts commit 8ed0fb6a07.
2025-09-25 14:35:12 +00:00
Benny Joo 8ed0fb6a07 perf: Replace isTeamMember util with an index DB call (#24066)
* Remove isTeamMember

* updates

* revert

* better
2025-09-25 12:55:14 +00:00
81 changed files with 1515 additions and 453 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
Reduced bundle size by removing unnecessary types
+1 -1
View File
@@ -1,5 +1,5 @@
/**/package.json @calcom/Foundation /**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation /apps/api/**/* @calcom/API
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation /apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation /apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/csp.ts @calcom/Foundation /apps/web/lib/csp.ts @calcom/Foundation
+1 -7
View File
@@ -74,14 +74,8 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit secrets: inherit
e2e-atoms:
name: Tests
needs: [lint, build, build-atoms, build-api-v2]
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
required: required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms] needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always() if: always()
runs-on: buildjet-2vcpu-ubuntu-2204 runs-on: buildjet-2vcpu-ubuntu-2204
steps: steps:
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
with: with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }} repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
organization-name: calcom organization-name: calcom
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier" ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
apply-labels-from-issue: apply-labels-from-issue:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -10
View File
@@ -195,13 +195,6 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit secrets: inherit
e2e-atoms:
name: Tests
needs: [changes, check-label, build, build-atoms, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
analyze: analyze:
name: Analyze Build name: Analyze Build
needs: [build] needs: [build]
@@ -211,7 +204,7 @@ jobs:
merge-reports: merge-reports:
name: Merge reports name: Merge reports
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms] needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
uses: ./.github/workflows/merge-reports.yml uses: ./.github/workflows/merge-reports.yml
secrets: inherit secrets: inherit
@@ -255,8 +248,7 @@ jobs:
e2e-api-v2, e2e-api-v2,
e2e-embed, e2e-embed,
e2e-embed-react, e2e-embed-react,
e2e-app-store, e2e-app-store
e2e-atoms,
] ]
if: always() if: always()
runs-on: buildjet-2vcpu-ubuntu-2204 runs-on: buildjet-2vcpu-ubuntu-2204
+1 -1
View File
@@ -59,7 +59,7 @@ export async function getHandler(req: NextApiRequest) {
}; };
} }
const [total, data] = await prisma.$transaction([ const [total, data] = await Promise.all([
prisma.user.count({ where }), prisma.user.count({ where }),
prisma.user.findMany({ where, take, skip }), prisma.user.findMany({ where, take, skip }),
]); ]);
@@ -47,6 +47,7 @@ import { OrganizationsTeamsController } from "@/modules/organizations/teams/inde
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service"; import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller"; import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository"; import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service"; import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module"; import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipsController, OrganizationsMembershipsController,
OrganizationsEventTypesController, OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController, OrganizationsTeamsMembershipsController,
OrganizationsTeamsInviteController,
OrganizationsAttributesController, OrganizationsAttributesController,
OrganizationsAttributesOptionsController, OrganizationsAttributesOptionsController,
OrganizationsWebhooksController, OrganizationsWebhooksController,
@@ -68,7 +68,7 @@ export class ManagedOrganizationsRepository {
managedOrganization: managedOrganizationFilter, managedOrganization: managedOrganizationFilter,
}; };
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([ const [totalItems, linkRows] = await Promise.all([
this.dbRead.prisma.managedOrganization.count({ where }), this.dbRead.prisma.managedOrganization.count({ where }),
this.dbRead.prisma.managedOrganization.findMany({ this.dbRead.prisma.managedOrganization.findMany({
where, where,
@@ -0,0 +1,135 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { 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 { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { Team, User } from "@calcom/prisma/client";
describe("Organizations Teams Invite Endpoints", () => {
describe("User Authentication - User is Org Team Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
const userEmail = `organizations-teams-invite-admin-${randomString()}@api.com`;
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: `organizations-teams-invite-organization-${randomString()}`,
isOrganization: true,
});
orgTeam = await teamsRepositoryFixture.create({
name: `organizations-teams-invite-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: `organizations-teams-invite-non-org-team-${randomString()}`,
isOrganization: false,
});
// Admin of the org team
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
// Also a member of the organization
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create a team invite", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data.token.length).toBeGreaterThan(0);
expect(response.body.data.inviteLink).toEqual(expect.any(String));
expect(response.body.data.inviteLink).toContain(response.body.data.token);
});
});
it("should create a new invite on each request", async () => {
const first = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200);
const firstToken = first.body.data.token as string;
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data.token).not.toEqual(firstToken);
expect(response.body.data.inviteLink).toEqual(expect.any(String));
expect(response.body.data.inviteLink).toContain(response.body.data.token);
});
});
it("should fail for team not in organization", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/invite`)
.expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,43 @@
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 { 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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { Controller, UseGuards, Post, Param, ParseIntPipe, HttpCode, HttpStatus } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamService } from "@calcom/platform-libraries";
import { CreateInviteOutputDto } from "./outputs/invite.output";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Invite")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsInviteController {
@Post("/invite")
@Roles("TEAM_ADMIN")
@ApiOperation({ summary: "Create team invite link" })
@HttpCode(HttpStatus.OK)
async createInvite(
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<CreateInviteOutputDto> {
const result = await TeamService.createInvite(teamId);
return { status: SUCCESS_STATUS, data: result };
}
}
@@ -0,0 +1,27 @@
import { ApiProperty } from "@nestjs/swagger";
export class InviteDataDto {
@ApiProperty({
description:
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
})
token!: string;
@ApiProperty({
description:
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
example: "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
})
inviteLink!: string;
}
export class CreateInviteOutputDto {
@ApiProperty({ example: "success" })
status!: string;
@ApiProperty({ type: InviteDataDto })
data!: InviteDataDto;
}
@@ -6,6 +6,8 @@ import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -33,9 +35,30 @@ const Page = async ({ params }: PageProps) => {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
let canReadOthersBookings = false;
if (session?.user?.id) {
const permissionService = new PermissionCheckService();
const userId = session.user.id;
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId,
permission: "booking.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
// We check if teamIdsWithPermission.length > 0.
// While this may not be entirely accurate, it's acceptable
// because we perform a thorough validation on the server side for the actual filter values.
// This variable is primarily for UI purposes.
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
return ( return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}> <ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} userId={session?.user?.id} /> <BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
/>
</ShellMainAppDir> </ShellMainAppDir>
); );
}; };
@@ -11,6 +11,7 @@ export const TeamsCTA = () => {
data-testid="new-team-btn" data-testid="new-team-btn"
variant="fab" variant="fab"
StartIcon="plus" StartIcon="plus"
size="sm"
type="button" type="button"
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}> href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")} {t("new")}
@@ -438,6 +438,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("profile")} name={t("profile")}
href={`/settings/teams/${team.id}/profile`} href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
)} )}
@@ -445,6 +446,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("members")} name={t("members")}
href={`/settings/teams/${team.id}/members`} href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
{/* Show roles only for sub-teams with PBAC-enabled parent */} {/* Show roles only for sub-teams with PBAC-enabled parent */}
@@ -465,6 +467,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("appearance")} name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`} href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
{/* Hide if there is a parent ID */} {/* Hide if there is a parent ID */}
@@ -474,6 +477,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("billing")} name={t("billing")}
href={`/settings/teams/${team.id}/billing`} href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
</> </>
@@ -482,6 +486,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("settings")} name={t("settings")}
href={`/settings/teams/${team.id}/settings`} href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
</> </>
@@ -643,6 +648,7 @@ const SettingsSidebarContainer = ({
name={t("add_a_team")} name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`} href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm" textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
icon="plus" icon="plus"
disableChevron disableChevron
/> />
@@ -744,12 +750,14 @@ const SettingsSidebarContainer = ({
name={t("profile")} name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`} href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
<VerticalTabItem <VerticalTabItem
name={t("members")} name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`} href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm" textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron disableChevron
/> />
<> <>
@@ -1,6 +1,16 @@
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { z } from "zod";
import CreateTeamEventType, { LayoutWrapper } from "~/settings/teams/[id]/event-types-view"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CreateTeamEventType, LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata( await _generateMetadata(
@@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/teams/${(await params).id}/event-type` `/settings/teams/${(await params).id}/event-type`
); );
const ServerPage = async () => { const querySchema = z.object({
id: z.coerce.number().refine((val) => !Number.isNaN(val), {
message: "id must be a string that can be cast to a number",
}),
});
const ServerPage = async ({ params }: ServerPageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const parsed = querySchema.safeParse(await params);
if (!parsed.success) {
notFound();
}
const permissionService = new PermissionCheckService();
const canCreateEventType = await permissionService.checkPermission({
userId: session.user.id,
teamId: parsed.data.id,
permission: "eventType.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
return ( return (
<LayoutWrapper> <LayoutWrapper>
<CreateTeamEventType /> <CreateTeamEventType permissions={{ canCreateEventType }} />
</LayoutWrapper> </LayoutWrapper>
); );
}; };
@@ -213,7 +213,6 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
); );
const [submit, setSubmit] = useState(false); const [submit, setSubmit] = useState(false);
const [mounted, setMounted] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated)); const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
useEffect(() => { useEffect(() => {
@@ -223,11 +222,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
} }
}, [submit, allUpdated, mainForSubmitRef]); }, [submit, allUpdated, mainForSubmitRef]);
useEffect(() => { if (!formPortalRef?.current) {
setMounted(true);
}, []);
if (!formPortalRef?.current || !mounted) {
return null; return null;
} }
@@ -81,7 +81,7 @@ describe("EditLocationDialog", () => {
}, },
{ {
value: "integrations:daily", value: "integrations:daily",
label: "Cal Video (Global)", label: "Cal Video (Default)",
disabled: false, disabled: false,
icon: "/app-store/dailyvideo/icon.svg", icon: "/app-store/dailyvideo/icon.svg",
slug: "daily-video", slug: "daily-video",
@@ -310,7 +310,6 @@ const OnboardingPage = ({
/> />
)} )}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && {currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
formPortalRef.current &&
eventTypeGroups && ( eventTypeGroups && (
<ConfigureStepCard <ConfigureStepCard
slug={appMetadata.slug} slug={appMetadata.slug}
@@ -57,6 +57,9 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
type BookingsProps = { type BookingsProps = {
status: (typeof validStatuses)[number]; status: (typeof validStatuses)[number];
userId?: number; userId?: number;
permissions: {
canReadOthersBookings: boolean;
};
}; };
function useSystemSegments(userId?: number) { function useSystemSegments(userId?: number) {
@@ -111,7 +114,7 @@ type RowData =
type: "today" | "next"; type: "today" | "next";
}; };
function BookingsContent({ status }: BookingsProps) { function BookingsContent({ status, permissions }: BookingsProps) {
const { t } = useLocale(); const { t } = useLocale();
const user = useMeQuery().data; const user = useMeQuery().data;
const tableContainerRef = useRef<HTMLDivElement>(null); const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -154,7 +157,7 @@ function BookingsContent({ status }: BookingsProps) {
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path, href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
"data-testid": tabConfig["data-testid"], "data-testid": tabConfig["data-testid"],
})); }));
}, [searchParams?.toString()]); }, [searchParams]);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined; const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined; const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
@@ -215,7 +218,7 @@ function BookingsContent({ status }: BookingsProps) {
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, { columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId", id: "userId",
header: t("member"), header: t("member"),
enableColumnFilter: user?.isTeamAdminOrOwner ?? false, enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false, enableSorting: false,
cell: () => null, cell: () => null,
meta: { meta: {
@@ -314,7 +317,7 @@ function BookingsContent({ status }: BookingsProps) {
}, },
}), }),
]; ];
}, [user, status, t]); }, [user, status, t, permissions.canReadOthersBookings]);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]); const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
@@ -352,7 +355,7 @@ function BookingsContent({ status }: BookingsProps) {
isToday: false, isToday: false,
})) || [] })) || []
); );
}, [query.data]); }, [query.data, status, user?.timeZone]);
const bookingsToday = useMemo<RowData[]>(() => { const bookingsToday = useMemo<RowData[]>(() => {
return ( return (
@@ -371,7 +374,7 @@ function BookingsContent({ status }: BookingsProps) {
isToday: true, isToday: true,
})) ?? [] })) ?? []
); );
}, [query.data]); }, [query.data, user?.timeZone]);
const finalData = useMemo<RowData[]>(() => { const finalData = useMemo<RowData[]>(() => {
if (status !== "upcoming") { if (status !== "upcoming") {
@@ -556,7 +556,9 @@ export default function Success(props: PageProps) {
<div className="font-medium"> <div className="font-medium">
{isCancelled ? t("reason") : t("reschedule_reason")} {isCancelled ? t("reason") : t("reschedule_reason")}
</div> </div>
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div> <div className="col-span-2 mb-6 last:mb-0">
<p className="break-words">{cancellationReason}</p>
</div>
</> </>
)} )}
{isCancelled && bookingInfo?.cancelledBy && ( {isCancelled && bookingInfo?.cancelledBy && (
@@ -12,7 +12,10 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton"; import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed"; import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import { EventTypeDescription } from "@calcom/features/eventtypes/components"; import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; import {
CreateEventTypeDialog,
type ProfileOption,
} from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader"; import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants"; import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
@@ -846,17 +849,7 @@ const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTe
); );
}; };
const CTA = ({ const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => {
profileOptions,
}: {
profileOptions: {
teamId: number | null | undefined;
label: string | null;
image: string;
membershipRole: MembershipRole | null | undefined;
slug: string | null;
}[];
}) => {
const { t } = useLocale(); const { t } = useLocale();
if (!profileOptions.length) return null; if (!profileOptions.length) return null;
@@ -951,7 +944,7 @@ type Props = {
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => { export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
const profileOptions = const profileOptions =
userEventGroupsData?.profiles userEventGroupsData.profiles
?.filter((profile) => !profile.readOnly) ?.filter((profile) => !profile.readOnly)
?.filter((profile) => !profile.eventTypesLockedByOrg) ?.filter((profile) => !profile.eventTypesLockedByOrg)
?.filter((profile) => { ?.filter((profile) => {
@@ -973,12 +966,20 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
); );
}) })
?.map((profile) => { ?.map((profile) => {
const permissions = profile.teamId
? userEventGroupsData.teamPermissions[profile.teamId]
: {
// always can create eventType on personal level
canCreateEventType: true,
};
return { return {
teamId: profile.teamId, teamId: profile.teamId,
label: profile.name || profile.slug, label: profile.name || profile.slug,
image: profile.image, image: profile.image,
membershipRole: profile.membershipRole, membershipRole: profile.membershipRole,
slug: profile.slug, slug: profile.slug,
permissions,
}; };
}) ?? []; }) ?? [];
@@ -8,11 +8,15 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType"; import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { WizardLayout } from "@calcom/ui/components/layout"; import { WizardLayout } from "@calcom/ui/components/layout";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { Button } from "@calcom/ui/components/button";
export const CreateTeamEventType = () => { type CreateTeamEventTypeProps = {
permissions: { canCreateEventType: boolean };
};
export const CreateTeamEventType = ({ permissions }: CreateTeamEventTypeProps) => {
const searchParams = useCompatSearchParams(); const searchParams = useCompatSearchParams();
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
@@ -53,7 +57,7 @@ export const CreateTeamEventType = () => {
<TeamEventTypeForm <TeamEventTypeForm
teamSlug={team?.slug} teamSlug={team?.slug}
teamId={teamId} teamId={teamId}
isTeamAdminOrOwner={true} permissions={permissions}
urlPrefix={urlPrefix} urlPrefix={urlPrefix}
isPending={createMutation.isPending} isPending={createMutation.isPending}
form={form} form={form}
@@ -82,5 +86,3 @@ export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
</WizardLayout> </WizardLayout>
); );
}; };
export default CreateTeamEventType;
+3 -2
View File
@@ -426,6 +426,7 @@ export default function Signup({
id="signup-email" id="signup-email"
{...register("email")} {...register("email")}
label={t("email")} label={t("email")}
placeholder="john@doe.com"
type="email" type="email"
autoComplete="email" autoComplete="email"
disabled={prepopulateFormValues?.email} disabled={prepopulateFormValues?.email}
@@ -522,8 +523,8 @@ export default function Signup({
usernameTaken usernameTaken
}> }>
{premiumUsername && !usernameTaken {premiumUsername && !usernameTaken
? `${t("create_account")} (${getPremiumPlanPriceValue()})` ? `${t("get_started")} (${getPremiumPlanPriceValue()})`
: t("create_account")} : t("get_started")}
</Button> </Button>
)} )}
</Form> </Form>
@@ -0,0 +1,51 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Booking Filters", () => {
test("Member role should not see the member filter", async ({ page, users }) => {
const teamMateName = "team mate 1";
await users.create(undefined, {
hasTeam: true,
isOrg: true,
teammates: [{ name: teamMateName }],
});
const allUsers = await users.get();
const memberUser = allUsers.find((user) => user.name === teamMateName);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!memberUser) {
throw new Error("user should exist");
}
await memberUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeHidden();
});
test("Admin role should see the member filter", async ({ page, users }) => {
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
});
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
});
});
+11 -2
View File
@@ -239,7 +239,7 @@ test.describe("Event Types tests", () => {
await gotoFirstEventType(page); await gotoFirstEventType(page);
await page.getByTestId("location-select").click(); await page.getByTestId("location-select").click();
await page.locator(`text="Cal Video (Global)"`).click(); await page.locator(`text="Cal Video (Default)"`).click();
await saveEventType(page); await saveEventType(page);
await gotoBookingPage(page); await gotoBookingPage(page);
@@ -282,7 +282,7 @@ test.describe("Event Types tests", () => {
await selectAttendeePhoneNumber(page); await selectAttendeePhoneNumber(page);
// Add Cal Video location // Add Cal Video location
await addAnotherLocation(page, "Cal Video (Global)"); await addAnotherLocation(page, "Cal Video (Default)");
await saveEventType(page); await saveEventType(page);
@@ -496,6 +496,9 @@ test.describe("Event Types tests", () => {
}); });
await test.step("should open first eventType and check Interface Language", async () => { await test.step("should open first eventType and check Interface Language", async () => {
await gotoFirstEventType(page); await gotoFirstEventType(page);
// Go to Advanced tab
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=event-interface-language-toggle]");
const interfaceLanguageValue = page const interfaceLanguageValue = page
.getByTestId("event-interface-language") .getByTestId("event-interface-language")
.locator('div[class$="-singleValue"]'); .locator('div[class$="-singleValue"]');
@@ -518,6 +521,9 @@ test.describe("Event Types tests", () => {
await test.step("should open first eventType and change Interface Language to Deutsche", async () => { await test.step("should open first eventType and change Interface Language to Deutsche", async () => {
await gotoFirstEventType(page); await gotoFirstEventType(page);
// Go to Advanced tab and enable offerSeats
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=event-interface-language-toggle]");
await page.getByTestId("event-interface-language").click(); await page.getByTestId("event-interface-language").click();
await page.locator(`text="Deutsch"`).click(); await page.locator(`text="Deutsch"`).click();
await saveEventType(page); await saveEventType(page);
@@ -574,6 +580,9 @@ test.describe("Event Types tests", () => {
await page.goto("/event-types"); await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]'); await page.waitForSelector('[data-testid="event-types"]');
await gotoFirstEventType(page); await gotoFirstEventType(page);
// Go to Advanced tab and enable offerSeats
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=event-interface-language-toggle]");
await page.getByTestId("event-interface-language").click(); await page.getByTestId("event-interface-language").click();
await page.getByTestId("select-option-es").click(); await page.getByTestId("select-option-es").click();
await saveEventType(page); await saveEventType(page);
+1 -1
View File
@@ -198,7 +198,7 @@ export async function expectSegmentCleared(page: Page) {
// Check that no segment is selected (button shows default text) // Check that no segment is selected (button shows default text)
const segmentSelect = page.getByTestId("filter-segment-select"); const segmentSelect = page.getByTestId("filter-segment-select");
const buttonText = await segmentSelect.textContent(); const buttonText = await segmentSelect.textContent();
expect(buttonText?.trim()).toEqual("Segment"); expect(buttonText?.trim()).toEqual("Saved filters");
} }
/** /**
@@ -199,6 +199,7 @@ export function createBookingPageFixture(page: Page) {
await expect(eventTypePage.getByTestId("time")).toHaveCount(count); await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
}, },
getAITranslationToggleDisabled: async () => { getAITranslationToggleDisabled: async () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.waitForSelector('[data-testid="ai_translation_toggle"]', { await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
timeout: 5000, timeout: 5000,
state: "attached", state: "attached",
@@ -207,6 +208,7 @@ export function createBookingPageFixture(page: Page) {
return (await toggle.getAttribute("disabled")) !== null; return (await toggle.getAttribute("disabled")) !== null;
}, },
toggleAITranslation: async () => { toggleAITranslation: async () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.getByTestId("ai_translation_toggle").click(); await page.getByTestId("ai_translation_toggle").click();
}, },
updateEventTypeDescription: async (description: string) => { updateEventTypeDescription: async (description: string) => {
@@ -81,10 +81,13 @@ test.describe("Managed Event Types", () => {
await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); await page.goto(`/event-types/${managedEvent.id}?tabName=setup`);
await expect(page.getByTestId("vertical-tab-basics")).toHaveAttribute("aria-current", "page"); // fix the race condition await expect(page.getByTestId("vertical-tab-basics")).toHaveAttribute("aria-current", "page"); // fix the race condition
await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); //fix the race condition await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); //fix the race condition
await page.locator("#location-select").click(); await page.locator("#location-select").click();
const optionText = await getByKey(page, "organizer_default_conferencing_app"); const targetText = (await localize("en"))("organizer_default_conferencing_app");
await expect(optionText).toBeVisible(); const options = page.locator('[data-testid="location-select-item-conferencing"]');
await optionText.click(); const targetOption = options.locator(`text=${targetText}`);
await expect(targetOption).toBeVisible();
await targetOption.click();
await saveAndWaitForResponse(page); await saveAndWaitForResponse(page);
await page.getByTestId("vertical-tab-assignment").click(); await page.getByTestId("vertical-tab-assignment").click();
@@ -190,7 +193,7 @@ test.describe("Managed Event Types", () => {
}); });
const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise<Locator> }[] = [ const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise<Locator> }[] = [
{ slug: "setup", locator: (page) => getByKey(page, "translate_description_button") }, { slug: "setup", locator: (page) => getByKey(page, "allow_multiple_durations") },
{ {
slug: "team", slug: "team",
locator: (page) => getByKey(page, "automatically_add_all_team_members"), locator: (page) => getByKey(page, "automatically_add_all_team_members"),
+1 -1
View File
@@ -209,7 +209,7 @@ test.describe("Email Signup Flow Test", async () => {
data: { data: {
identifier: userToCreate.email, identifier: userToCreate.email,
token, token,
expires: new Date(new Date().setHours(168)), // +1 week expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: { team: {
create: { create: {
name: "Rick's Team", name: "Rick's Team",
+23 -7
View File
@@ -321,6 +321,7 @@
"secret_meeting": "Secret Meeting", "secret_meeting": "Secret Meeting",
"login_instead": "Login instead", "login_instead": "Login instead",
"create_account": "Create Account", "create_account": "Create Account",
"get_started": "Get started",
"confirm_password": "Confirm password", "confirm_password": "Confirm password",
"reset_your_password": "Set your new password with the instructions sent to your email address.", "reset_your_password": "Set your new password with the instructions sent to your email address.",
"org_banner_instructions": "Please upload image of {{width}} width and {{height}} height.", "org_banner_instructions": "Please upload image of {{width}} width and {{height}} height.",
@@ -958,7 +959,7 @@
"light_event_type_color": "Event Type Color (Light Theme)", "light_event_type_color": "Event Type Color (Light Theme)",
"dark_event_type_color": "Event Type Color (Dark Theme)", "dark_event_type_color": "Event Type Color (Dark Theme)",
"file_not_named": "File is not named [idOrSlug]/[user]", "file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create Team", "create_team": "Create team",
"name": "Name", "name": "Name",
"nameless_team": "Nameless Team", "nameless_team": "Nameless Team",
"oauth_clients": "OAuth Clients", "oauth_clients": "OAuth Clients",
@@ -1019,6 +1020,7 @@
"attendees": "Attendees", "attendees": "Attendees",
"add_attendees": "Add attendees", "add_attendees": "Add attendees",
"show_advanced_settings": "Show advanced settings", "show_advanced_settings": "Show advanced settings",
"hide_advanced_settings": "Hide advanced settings",
"event_name": "Event Name", "event_name": "Event Name",
"event_name_in_calendar": "Calendar event name", "event_name_in_calendar": "Calendar event name",
"event_name_tooltip": "The name that will appear in calendars", "event_name_tooltip": "The name that will appear in calendars",
@@ -1231,7 +1233,7 @@
"contact_sales": "Contact Sales", "contact_sales": "Contact Sales",
"error_404": "Error 404", "error_404": "Error 404",
"default": "Default", "default": "Default",
"set_to_default": "Set to Default", "set_to_default": "Set as Default",
"new_schedule_btn": "New schedule", "new_schedule_btn": "New schedule",
"add_new_schedule": "Add a new schedule", "add_new_schedule": "Add a new schedule",
"add_new_calendar": "Add a new calendar", "add_new_calendar": "Add a new calendar",
@@ -1329,7 +1331,7 @@
"make_team_private_description": "Your team members won't be able to see other team members when this is turned on.", "make_team_private_description": "Your team members won't be able to see other team members when this is turned on.",
"you_cannot_see_team_members": "You cannot see all the team members of a private team.", "you_cannot_see_team_members": "You cannot see all the team members of a private team.",
"you_cannot_see_teams_of_org": "You cannot see teams of a private organization.", "you_cannot_see_teams_of_org": "You cannot see teams of a private organization.",
"allow_booker_to_select_duration": "Allow booker to select from multiple durations", "allow_multiple_durations": "Allow multiple durations",
"impersonate_user_tip": "All uses of this feature is audited.", "impersonate_user_tip": "All uses of this feature is audited.",
"impersonating_user_warning": "Impersonating username \"{{user}}\".", "impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "Click here to stop", "impersonating_stop_instructions": "Click here to stop",
@@ -2689,7 +2691,7 @@
"enterprise": "Enterprise", "enterprise": "Enterprise",
"enterprise_description": "Upgrade to Enterprise to create your Organization", "enterprise_description": "Upgrade to Enterprise to create your Organization",
"create_your_org": "Create your Organization", "create_your_org": "Create your Organization",
"create_org": "Create Organization", "create_org": "Create organization",
"create_your_org_description": "Upgrade to Organizations and receive a subdomain, unified billing, Insights, extensive whitelabeling and more", "create_your_org_description": "Upgrade to Organizations and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"create_your_enterprise_description": "Upgrade to Enterprise and get access to Active Directory Sync, SCIM Automatic User provisioning, Cal.ai Voice Agents, Admin APIs and more!", "create_your_enterprise_description": "Upgrade to Enterprise and get access to Active Directory Sync, SCIM Automatic User provisioning, Cal.ai Voice Agents, Admin APIs and more!",
"other_payment_app_enabled": "You can only enable one payment app per event type", "other_payment_app_enabled": "You can only enable one payment app per event type",
@@ -2923,6 +2925,7 @@
"proceeding_agreement": "By proceeding, you agree to our", "proceeding_agreement": "By proceeding, you agree to our",
"terms": "Terms", "terms": "Terms",
"always_show_x_days": "Always {{x}} days available", "always_show_x_days": "Always {{x}} days available",
"always_show_x_days_description": "Automatically moves the {{x}}-day booking window forward each day, ensuring your calendar always shows the next {{x}} days of available slots.",
"team_subscription_cancelled_successfully": "Team subscription cancelled successfully", "team_subscription_cancelled_successfully": "Team subscription cancelled successfully",
"team_subscription_cancellation_error": "Error while cancelling the team subscription", "team_subscription_cancellation_error": "Error while cancelling the team subscription",
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later", "unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
@@ -3144,7 +3147,8 @@
"filter_operator_ends_with": "Ends with", "filter_operator_ends_with": "Ends with",
"filter_operator_is_empty": "Is empty", "filter_operator_is_empty": "Is empty",
"filter_operator_not_empty": "Not empty", "filter_operator_not_empty": "Not empty",
"translate_description_button": "Translate title/description to the visitor's browser language using AI", "translate_description_button": "Auto translate title and description",
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.",
"rr_distribution_method": "Distribution", "rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.", "rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"rr_distribution_method_availability_title": "Maximize availability", "rr_distribution_method_availability_title": "Maximize availability",
@@ -3310,6 +3314,7 @@
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.", "seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.", "recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
"segment": "Segment", "segment": "Segment",
"saved_filters": "Saved filters",
"rename": "Rename", "rename": "Rename",
"rename_segment": "Rename Segment", "rename_segment": "Rename Segment",
"delete_segment": "Delete Segment", "delete_segment": "Delete Segment",
@@ -3339,6 +3344,7 @@
"picklist": "Picklist", "picklist": "Picklist",
"most_cancelled_bookings": "Most Cancelled", "most_cancelled_bookings": "Most Cancelled",
"interface_language": "Interface Language", "interface_language": "Interface Language",
"interface_language_description": "Set your preferred language for the booking interface",
"visitors_browser_language": "Visitor's browser language", "visitors_browser_language": "Visitor's browser language",
"name_or_email": "Name or Email", "name_or_email": "Name or Email",
"salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists", "salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists",
@@ -3510,10 +3516,20 @@
"pbac_desc_update_workflows": "Edit and modify workflow settings", "pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system", "pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_resource_webhook": "Webhook", "pbac_resource_webhook": "Webhook",
"pbac_resource_availability": "Availability",
"pbac_resource_out_of_office": "Out of Office",
"pbac_desc_create_webhooks": "Create webhooks", "pbac_desc_create_webhooks": "Create webhooks",
"pbac_desc_view_webhooks": "View webhooks", "pbac_desc_view_webhooks": "View webhooks",
"pbac_desc_update_webhooks": "Update webhooks", "pbac_desc_update_webhooks": "Update webhooks",
"pbac_desc_delete_webhooks": "Delete webhooks", "pbac_desc_delete_webhooks": "Delete webhooks",
"pbac_desc_create_availability": "Create availability",
"pbac_desc_view_availability": "View availability",
"pbac_desc_update_availability": "Update availability",
"pbac_desc_delete_availability": "Delete availability",
"pbac_desc_create_out_of_office": "Create out of office",
"pbac_desc_view_out_of_office": "View out of office",
"pbac_desc_update_out_of_office": "Update out of office",
"pbac_desc_delete_out_of_office": "Delete out of office",
"pbac_desc_manage_workflows": "Full management access to all workflows", "pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types", "pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types", "pbac_desc_view_event_types": "View event types",
@@ -3561,8 +3577,8 @@
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups", "rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
"round_robin_settings_description": "Customize the default round robin settings for this team", "round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully", "round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time", "booking_creation_time": "Booking creation time",
"meeting_start_time": "Meeting Start Time", "meeting_start_time": "Meeting start time",
"members_affected_by_disabling_delegation_credential": "Affected members", "members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential", "no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"download_expense_log": "Download Expense Log", "download_expense_log": "Download Expense Log",
+95 -3
View File
@@ -4421,6 +4421,70 @@
"tags": ["Orgs / Teams / Event Types / Private Links"] "tags": ["Orgs / Teams / Event Types / Private Links"]
} }
}, },
"/v2/organizations/{orgId}/teams/{teamId}/invite": {
"post": {
"operationId": "OrganizationsTeamsInviteController_createInvite",
"summary": "Create team invite link",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateInviteOutputDto"
}
}
}
}
},
"tags": ["Orgs / Teams / Invite"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": { "/v2/organizations/{orgId}/teams/{teamId}/memberships": {
"get": { "get": {
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships", "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -15219,9 +15283,8 @@
"properties": { "properties": {
"disabled": { "disabled": {
"type": "boolean", "type": "boolean",
"description": "Indicates if the option is disabled", "description": "Only acceptable value for the `disabled` property is `true`. It is used to reset the value of the property for which previously an object containing specific settings was passed.",
"example": true, "example": true
"default": false
} }
}, },
"required": ["disabled"] "required": ["disabled"]
@@ -19915,6 +19978,35 @@
}, },
"required": ["userId", "role"] "required": ["userId", "role"]
}, },
"InviteDataDto": {
"type": "object",
"properties": {
"token": {
"type": "string",
"description": "Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
"example": "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2"
},
"inviteLink": {
"type": "string",
"description": "Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
"example": "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started"
}
},
"required": ["token", "inviteLink"]
},
"CreateInviteOutputDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"data": {
"$ref": "#/components/schemas/InviteDataDto"
}
},
"required": ["status", "data"]
},
"Attribute": { "Attribute": {
"type": "object", "type": "object",
"properties": { "properties": {
+2 -1
View File
@@ -213,7 +213,8 @@
"platform/atoms/event-type", "platform/atoms/event-type",
"platform/atoms/calendar-settings", "platform/atoms/calendar-settings",
"platform/atoms/payment-form", "platform/atoms/payment-form",
"platform/atoms/conferencing-apps" "platform/atoms/conferencing-apps",
"platform/atoms/calendar-view"
] ]
}, },
{ {
+61
View File
@@ -0,0 +1,61 @@
---
title: "Calendar view"
---
The calendar view atom is basically a weekly calendar view wherein users can see entire week at a glance, clearly showing both available and unavailable time slots. This view is designed to provide a comprehensive and easy-to-read schedule, helping users quickly identify open slots for booking or planning.
## Individual event type
Below code snippet can be used to render the calendar view atom for an individual event
```js
import { CalendarView } from "@calcom/atoms";
export default function Booker( props : BookerProps ) {
return (
<div>
<CalendarView username={props.calUsername} eventSlug={props.eventSlug} />
</div>
)
}
```
## Team event type
Below code snippet can be used to render the calendar view atom for a team event
```js
import { CalendarView } from "@calcom/atoms";
export default function Booker( props : BookerProps ) {
return (
<div>
<CalendarView isTeamEvent={true} teamId={props.teamId} eventSlug={props.eventSlug} />
</div>
)
}
```
For a demonstration of the calendar view atom, please refer to the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/40ebab9efaa149019ea487e35ff8f656?sid=c6a748c3-0564-4400-ac7c-a17a404c9af6"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
Below is a list of props that can be passed to the create event type atom
| Name | Required | Description |
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
| username | Yes | Username of the person whose schedule is to be displayed |
| eventSlug | Yes | Unique slug created for a particular event | |
| isTeamEvent | No | Boolean indicating if it is a team event, to be passed only for team events |
| teamId | No | The id of the team for which the event is created, to be passed only for team events |
+1 -1
View File
@@ -332,7 +332,7 @@ Below video shows a demonstration of the all the options you get in the advanced
<iframe <iframe
height="315" height="315"
style={{ width: "100%", maxWidth: "560px" }} style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/e0121b51920544eea92251dba99245fa?sid=ac627de7-5673-41b4-aa78-c6c9e439bdb5" src="https://www.loom.com/embed/7a03c4a453ca401ab787b2db98728f31?sid=8cdc6398-0017-41c8-a0a1-8fa31f80616c"
frameborder="0" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true" allowfullscreen="true"
@@ -1,4 +1,5 @@
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -23,25 +24,53 @@ export class OrganizationBillingPortalService extends BillingPortalService {
}); });
} }
async getCustomerId(teamId: number): Promise<string | null> { async getValidatedTeamSubscriptionId(metadata: Prisma.JsonValue) {
const log = logger.getSubLogger({ prefix: ["OrganizationBillingPortalService", "getCustomerId"] }); const teamMetadataParsed = teamMetadataSchema.safeParse(metadata);
const team = await this.teamRepository.findById({ id: teamId });
if (!team) return null;
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) { if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
return null; return null;
} }
return teamMetadataParsed.data.subscriptionId;
}
async getValidatedTeamSubscriptionIdForPlatform(subscriptionId?: string | null) {
if (!subscriptionId) {
return null;
}
return subscriptionId;
}
async getCustomerId(teamId: number): Promise<string | null> {
const log = logger.getSubLogger({ prefix: ["OrganizationBillingPortalService", "getCustomerId"] });
const team = await this.teamRepository.findByIdIncludePlatformBilling({ id: teamId });
if (!team) return null;
let teamSubscriptionId = "";
if (team.isPlatform) {
const subscriptionId = await this.getValidatedTeamSubscriptionIdForPlatform(
team.platformBilling?.subscriptionId
);
if (!subscriptionId) return null;
teamSubscriptionId = subscriptionId;
} else {
const subscriptionId = await this.getValidatedTeamSubscriptionId(team.metadata);
if (!subscriptionId) return null;
teamSubscriptionId = subscriptionId;
}
try { try {
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId); const subscription = await getSubscriptionFromId(teamSubscriptionId);
if (!subscription?.customer) { if (!subscription?.customer) {
log.warn("Subscription found but no customer ID", { log.warn("Subscription found but no customer ID", {
teamId, teamId,
subscriptionId: teamMetadataParsed.data.subscriptionId, subscriptionId: teamSubscriptionId,
}); });
return null; return null;
} }
+1 -1
View File
@@ -68,7 +68,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
delegatedToId: null, delegatedToId: null,
delegationCredentialId: null, delegationCredentialId: null,
team: { team: {
name: "Global", name: "Default",
}, },
}; };
logger.debug( logger.debug(
+2 -1
View File
@@ -36,6 +36,7 @@ export default function generateIcsFile({
status, status,
t, t,
}), }),
method: "REQUEST", type: "text/calendar; charset=UTF-8; method=REQUEST",
disposition: "attachment",
}; };
} }
@@ -123,7 +123,7 @@ function CategoryTab({ selectedCategory, categories, searchText, onCategoryChang
selectedCategory === cat ? "bg-emphasis text-default" : "bg-muted text-emphasis", selectedCategory === cat ? "bg-emphasis text-default" : "bg-muted text-emphasis",
"hover:bg-emphasis rounded-md px-4 py-2.5 text-sm font-medium transition hover:cursor-pointer" "hover:bg-emphasis rounded-md px-4 py-2.5 text-sm font-medium transition hover:cursor-pointer"
)}> )}>
{cat[0].toUpperCase() + cat.slice(1)} {cat === "crm" ? cat.toUpperCase() : cat[0].toUpperCase() + cat.slice(1)}
</li> </li>
))} ))}
</ul> </ul>
@@ -19,6 +19,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button"; import { Button } from "@calcom/ui/components/button";
import type { ButtonProps } from "@calcom/ui/components/button"; import type { ButtonProps } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
interface AppCardProps { interface AppCardProps {
app: App; app: App;
@@ -119,14 +120,14 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
</div> */} </div> */}
<p <p
className="text-default mt-2 flex-grow text-sm" className="text-default mt-2 flex-grow text-sm"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(app.description) }}
style={{ style={{
overflow: "hidden", overflow: "hidden",
display: "-webkit-box", display: "-webkit-box",
WebkitBoxOrient: "vertical", WebkitBoxOrient: "vertical",
WebkitLineClamp: "3", WebkitLineClamp: "3",
}}> }}/>
{app.description}
</p>
<div className="mt-5 flex max-w-full flex-row justify-between gap-2"> <div className="mt-5 flex max-w-full flex-row justify-between gap-2">
<Button <Button
@@ -74,15 +74,15 @@ function ColumnVisibilityButtonComponent<TData>(
})} })}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
<CommandSeparator /> <CommandSeparator className="mb-0" />
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
allColumns.forEach((column) => column.toggleVisibility(true)); allColumns.forEach((column) => column.toggleVisibility(true));
}} }}
className={classNames( className={classNames(
"w-full justify-center text-center", buttonClasses({ color: "secondary" }),
buttonClasses({ color: "secondary" }) "w-full justify-center text-center cursor-pointer mt-0"
)}> )}>
{t("show_all_columns")} {t("show_all_columns")}
</CommandItem> </CommandItem>
@@ -155,11 +155,11 @@ export function FilterSegmentSelect() {
StartIcon="list-filter" StartIcon="list-filter"
EndIcon="chevron-down" EndIcon="chevron-down"
data-testid="filter-segment-select"> data-testid="filter-segment-select">
{selectedSegment?.name || t("segment")} {selectedSegment?.name || t("saved_filters")}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent align="start" className="w-60" data-testid="filter-segment-select-content"> <DropdownMenuContent align="end" className="w-60" data-testid="filter-segment-select-content">
{segmentGroups.length === 0 && <p className="text-subtle px-3 py-1">{t("no_segments")}</p>} {segmentGroups.length === 0 && <p className="text-subtle px-3 py-1">{t("no_segments")}</p>}
{segmentGroups.map((group, index) => ( {segmentGroups.map((group, index) => (
@@ -157,7 +157,7 @@ export default function ApiKeyDialogForm({
{defaultValues ? t("edit_api_key") : t("create_api_key")} {defaultValues ? t("edit_api_key") : t("create_api_key")}
</h2> </h2>
{IS_CALCOM ? ( {IS_CALCOM ? (
<div className="mt-4 block gap-2 sm:flex"> <div className="mt-4 flex flex-col gap-2 sm:flex-row">
<div className="border-emphasis relative flex w-full items-start rounded-[10px] border p-4 text-sm"> <div className="border-emphasis relative flex w-full items-start rounded-[10px] border p-4 text-sm">
{t("api_key_modal_subtitle")} {t("api_key_modal_subtitle")}
</div> </div>
@@ -178,7 +178,7 @@ export default function ApiKeyDialogForm({
<Controller <Controller
name="note" name="note"
control={form.control} control={form.control}
render={({ field: { onChange, value } }) => ( render={({ field: { value } }) => (
<TextField <TextField
name="note" name="note"
label={t("personal_note")} label={t("personal_note")}
@@ -211,7 +211,7 @@ export default function ApiKeyDialogForm({
</div> </div>
<Controller <Controller
name="expiresAt" name="expiresAt"
render={({ field: { onChange, value } }) => { render={({ field: { onChange } }) => {
const defaultValue = expiresAtOptions[1]; const defaultValue = expiresAtOptions[1];
return ( return (
@@ -45,7 +45,7 @@ const MakeTeamPrivateSwitch = ({
setTeamPrivate(checked); setTeamPrivate(checked);
mutation.mutate({ id: teamId, isPrivate: checked }); mutation.mutate({ id: teamId, isPrivate: checked });
}} }}
switchContainerClassName="my-6" switchContainerClassName="mt-6"
data-testid="make-team-private-check" data-testid="make-team-private-check"
/> />
</> </>
@@ -14,7 +14,9 @@ import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { Tooltip } from "@calcom/ui/components/tooltip"; import { Tooltip } from "@calcom/ui/components/tooltip";
type props = { type props = {
isTeamAdminOrOwner: boolean; permissions: {
canCreateEventType: boolean;
};
teamSlug?: string | null; teamSlug?: string | null;
teamId: number; teamId: number;
isPending: boolean; isPending: boolean;
@@ -25,7 +27,7 @@ type props = {
SubmitButton: (isPending: boolean) => ReactNode; SubmitButton: (isPending: boolean) => ReactNode;
}; };
export const TeamEventTypeForm = ({ export const TeamEventTypeForm = ({
isTeamAdminOrOwner, permissions,
teamSlug, teamSlug,
teamId, teamId,
form, form,
@@ -40,6 +42,7 @@ export const TeamEventTypeForm = ({
const { t } = useLocale(); const { t } = useLocale();
const { register, setValue, formState } = form; const { register, setValue, formState } = form;
const { canCreateEventType } = permissions;
return ( return (
<Form form={form} handleSubmit={handleSubmit}> <Form form={form} handleSubmit={handleSubmit}>
@@ -124,29 +127,29 @@ export const TeamEventTypeForm = ({
onValueChange={(val: SchedulingType) => { onValueChange={(val: SchedulingType) => {
setValue("schedulingType", val); setValue("schedulingType", val);
}} }}
className={classNames("mt-1 flex gap-4", isTeamAdminOrOwner && "flex-col")}> className={classNames("mt-1 flex gap-4", canCreateEventType && "flex-col")}>
<RadioArea.Item <RadioArea.Item
{...register("schedulingType")} {...register("schedulingType")}
value={SchedulingType.COLLECTIVE} value={SchedulingType.COLLECTIVE}
className={classNames("w-full text-sm", !isTeamAdminOrOwner && "w-1/2")} className={classNames("w-full text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}> classNames={{ container: classNames(canCreateEventType && "w-full") }}>
<strong className="mb-1 block">{t("collective")}</strong> <strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p> <p>{t("collective_description")}</p>
</RadioArea.Item> </RadioArea.Item>
<RadioArea.Item <RadioArea.Item
{...register("schedulingType")} {...register("schedulingType")}
value={SchedulingType.ROUND_ROBIN} value={SchedulingType.ROUND_ROBIN}
className={classNames("text-sm", !isTeamAdminOrOwner && "w-1/2")} className={classNames("text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}> classNames={{ container: classNames(canCreateEventType && "w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong> <strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p> <p>{t("round_robin_description")}</p>
</RadioArea.Item> </RadioArea.Item>
{isTeamAdminOrOwner && ( {canCreateEventType && (
<RadioArea.Item <RadioArea.Item
{...register("schedulingType")} {...register("schedulingType")}
value={SchedulingType.MANAGED} value={SchedulingType.MANAGED}
className={classNames("text-sm", !isTeamAdminOrOwner && "w-1/2")} className={classNames("text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }} classNames={{ container: classNames(canCreateEventType && "w-full") }}
data-testid="managed-event-type"> data-testid="managed-event-type">
<strong className="mb-1 block">{t("managed_event")}</strong> <strong className="mb-1 block">{t("managed_event")}</strong>
<p>{t("managed_event_description")}</p> <p>{t("managed_event_description")}</p>
@@ -229,7 +229,7 @@ export default function WorkflowListPage({ workflows }: Props) {
<div> <div>
<div className="hidden md:block"> <div className="hidden md:block">
{workflow.team?.name && ( {workflow.team?.name && (
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray"> <Badge className="mr-4 mt-1 mb-2 p-[1px] px-2" variant="gray">
<Avatar <Avatar
alt={workflow.team?.name || ""} alt={workflow.team?.name || ""}
href={ href={
@@ -2,7 +2,6 @@ import { isValidPhoneNumber } from "libphonenumber-js";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Dialog } from "@calcom/features/components/controlled-dialog"; import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm"; import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm";
@@ -28,6 +27,17 @@ export interface EventTypeParent {
image?: string | null; image?: string | null;
} }
export interface ProfileOption {
teamId: number | null | undefined;
label: string | null;
image: string;
membershipRole: MembershipRole | null | undefined;
slug: string | null;
permissions: {
canCreateEventType: boolean;
};
}
const locationFormSchema = z.array( const locationFormSchema = z.array(
z.object({ z.object({
locationType: z.string(), locationType: z.string(),
@@ -55,16 +65,7 @@ const querySchema = z.object({
.optional(), .optional(),
}); });
export default function CreateEventTypeDialog({ export function CreateEventTypeDialog({ profileOptions }: { profileOptions: ProfileOption[] }) {
profileOptions,
}: {
profileOptions: {
teamId: number | null | undefined;
label: string | null;
image: string | undefined;
membershipRole: MembershipRole | null | undefined;
}[];
}) {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const orgBranding = useOrgBranding(); const orgBranding = useOrgBranding();
@@ -75,7 +76,7 @@ export default function CreateEventTypeDialog({
const teamProfile = profileOptions.find((profile) => profile.teamId === teamId); const teamProfile = profileOptions.find((profile) => profile.teamId === teamId);
const isTeamAdminOrOwner = teamId !== undefined && checkAdminOrOwner(teamProfile?.membershipRole); const permissions = teamProfile?.permissions ?? { canCreateEventType: false };
const onSuccessMutation = (eventType: EventType) => { const onSuccessMutation = (eventType: EventType) => {
router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`); router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`);
@@ -124,7 +125,7 @@ export default function CreateEventTypeDialog({
<TeamEventTypeForm <TeamEventTypeForm
teamSlug={team?.slug} teamSlug={team?.slug}
teamId={teamId} teamId={teamId}
isTeamAdminOrOwner={isTeamAdminOrOwner} permissions={permissions}
urlPrefix={urlPrefix} urlPrefix={urlPrefix}
isPending={createMutation.isPending} isPending={createMutation.isPending}
form={form} form={form}
@@ -1,7 +1,7 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
export { default as CheckedTeamSelect } from "./CheckedTeamSelect"; export { default as CheckedTeamSelect } from "./CheckedTeamSelect";
export { default as CreateEventTypeDialog } from "./CreateEventTypeDialog"; export { CreateEventTypeDialog } from "./CreateEventTypeDialog";
export { default as EventTypeDescription } from "./EventTypeDescription"; export { default as EventTypeDescription } from "./EventTypeDescription";
export { MultiplePrivateLinksController } from "./MultiplePrivateLinksController"; export { MultiplePrivateLinksController } from "./MultiplePrivateLinksController";
export const EventTypeDescriptionLazy = dynamic(() => import("./EventTypeDescription")); export const EventTypeDescriptionLazy = dynamic(() => import("./EventTypeDescription"));
@@ -11,132 +11,159 @@ import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form"; import { SettingsToggle } from "@calcom/ui/components/form";
import LocationSettingsContainer from "./LocationSettingsContainer"; import LocationSettingsContainer from "./LocationSettingsContainer";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { Icon } from "@calcom/ui/components/icon";
import { useState } from "react";
import { useAutoAnimate } from "@formkit/auto-animate/react";
const CalVideoSettings = ({ calVideoSettings }: { calVideoSettings?: CalVideoSettingsType }) => { const CalVideoSettings = ({ calVideoSettings }: { calVideoSettings?: CalVideoSettingsType }) => {
const { t } = useLocale(); const { t } = useLocale();
const formMethods = useFormContext<FormValues>(); const formMethods = useFormContext<FormValues>();
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const [isExpanded, setIsExpanded] = useState(false);
const [parent] = useAutoAnimate<HTMLDivElement>();
return ( return (
<LocationSettingsContainer> <>
<Controller <Tooltip content="expandable" side="right" className="lg:hidden">
name="calVideoSettings.disableRecordingForGuests" <button
defaultValue={!!calVideoSettings?.disableRecordingForGuests} aria-expanded={isExpanded}
render={({ field: { onChange, value } }) => { onClick={() => setIsExpanded(!isExpanded)}
return ( className={classNames(
<SettingsToggle "todesktop:py-[7px] text-default group flex w-full items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
title={t("disable_recording_for_guests")} "[&[aria-current='page']]:!bg-transparent",
labelClassName="text-sm leading-6 whitespace-normal break-words" "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
checked={value} )}>
onCheckedChange={onChange} <span className="hidden w-full justify-between truncate text-ellipsis lg:flex">
Badge={<UpgradeTeamsBadge checkForActiveStatus />} {!isExpanded ? t("show_advanced_settings") : t("hide_advanced_settings")}
</span>
<Icon name={isExpanded ? "chevron-up" : "chevron-down"} className="ml-auto h-4 w-4" />
</button>
</Tooltip>
<div ref={parent}>
{isExpanded && (
<LocationSettingsContainer>
<Controller
name="calVideoSettings.disableRecordingForGuests"
defaultValue={!!calVideoSettings?.disableRecordingForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/> />
);
}}
/>
<Controller <Controller
name="calVideoSettings.disableRecordingForOrganizer" name="calVideoSettings.disableRecordingForOrganizer"
defaultValue={!!calVideoSettings?.disableRecordingForOrganizer} defaultValue={!!calVideoSettings?.disableRecordingForOrganizer}
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<SettingsToggle <SettingsToggle
title={t("disable_recording_for_organizer")} title={t("disable_recording_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words" labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value} checked={value}
onCheckedChange={onChange} onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />} Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/> />
);
}}
/>
{!isPlatform && ( {!isPlatform && (
<Controller <Controller
name="calVideoSettings.enableAutomaticRecordingForOrganizer" name="calVideoSettings.enableAutomaticRecordingForOrganizer"
defaultValue={!!calVideoSettings?.enableAutomaticRecordingForOrganizer} defaultValue={!!calVideoSettings?.enableAutomaticRecordingForOrganizer}
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<SettingsToggle <SettingsToggle
title={t("enable_automatic_recording")} title={t("enable_automatic_recording")}
labelClassName="text-sm" labelClassName="text-sm"
checked={value} checked={value}
onCheckedChange={onChange} onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />} Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/> />
); )}
}}
/>
)}
<Controller <Controller
name="calVideoSettings.enableAutomaticTranscription" name="calVideoSettings.enableAutomaticTranscription"
defaultValue={!!calVideoSettings?.enableAutomaticTranscription} defaultValue={!!calVideoSettings?.enableAutomaticTranscription}
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<SettingsToggle <SettingsToggle
title={t("enable_automatic_transcription")} title={t("enable_automatic_transcription")}
labelClassName="text-sm leading-6 whitespace-normal break-words" labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value} checked={value}
onCheckedChange={onChange} onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />} Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/> />
);
}}
/>
{!isPlatform && ( {!isPlatform && (
<Controller <Controller
name="calVideoSettings.disableTranscriptionForGuests" name="calVideoSettings.disableTranscriptionForGuests"
defaultValue={!!calVideoSettings?.disableTranscriptionForGuests} defaultValue={!!calVideoSettings?.disableTranscriptionForGuests}
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<SettingsToggle <SettingsToggle
title={t("disable_transcription_for_guests")} title={t("disable_transcription_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words" labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value} checked={value}
onCheckedChange={onChange} onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />} Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/> />
); )}
}} {!isPlatform && (
/> <Controller
)} name="calVideoSettings.disableTranscriptionForOrganizer"
{!isPlatform && ( defaultValue={!!calVideoSettings?.disableTranscriptionForOrganizer}
<Controller render={({ field: { onChange, value } }) => {
name="calVideoSettings.disableTranscriptionForOrganizer" return (
defaultValue={!!calVideoSettings?.disableTranscriptionForOrganizer} <SettingsToggle
render={({ field: { onChange, value } }) => { title={t("disable_transcription_for_organizer")}
return ( labelClassName="text-sm leading-6 whitespace-normal break-words"
<SettingsToggle checked={value}
title={t("disable_transcription_for_organizer")} onCheckedChange={onChange}
labelClassName="text-sm leading-6 whitespace-normal break-words" Badge={<UpgradeTeamsBadge checkForActiveStatus />}
checked={value} />
onCheckedChange={onChange} );
Badge={<UpgradeTeamsBadge checkForActiveStatus />} }}
/> />
); )}
}}
/>
)}
<TextField <TextField
label={t("enter_redirect_url_on_exit_description")} label={t("enter_redirect_url_on_exit_description")}
defaultValue={calVideoSettings?.redirectUrlOnExit || ""} defaultValue={calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit" data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-4" containerClassName="mt-4"
className="leading-6" className="leading-6"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", { {...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v), setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})} })}
/> />
<ErrorMessage <ErrorMessage
errors={formMethods.formState.errors?.calVideoSettings} errors={formMethods.formState.errors?.calVideoSettings}
name="redirectUrlOnExit" name="redirectUrlOnExit"
className={classNames("text-error text-sm")} className={classNames("text-error text-sm")}
as="div" as="div"
id="calVideoSettings.redirectUrlOnExit-error" id="calVideoSettings.redirectUrlOnExit-error"
/> />
</LocationSettingsContainer> </LocationSettingsContainer>
)}
</div>
</>
); );
}; };
@@ -60,6 +60,7 @@ import {
CheckboxField, CheckboxField,
Switch, Switch,
SettingsToggle, SettingsToggle,
Select,
} from "@calcom/ui/components/form"; } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon"; import { Icon } from "@calcom/ui/components/icon";
@@ -120,6 +121,7 @@ export type EventAdvancedBaseProps = Pick<EventTypeSetupProps, "eventType" | "te
>; >;
isUserLoading?: boolean; isUserLoading?: boolean;
showToast: (message: string, variant: "success" | "warning" | "error") => void; showToast: (message: string, variant: "success" | "warning" | "error") => void;
orgId: number | null;
customClassNames?: EventAdvancedTabCustomClassNames; customClassNames?: EventAdvancedTabCustomClassNames;
}; };
@@ -130,6 +132,7 @@ export type EventAdvancedTabProps = EventAdvancedBaseProps & {
error: unknown; error: unknown;
}; };
showBookerLayoutSelector: boolean; showBookerLayoutSelector: boolean;
localeOptions?: { value: string; label: string }[];
verifiedEmails?: string[]; verifiedEmails?: string[];
}; };
@@ -253,6 +256,11 @@ const destinationCalendarComponents = {
/> />
</div> </div>
)} )}
{!showConnectedCalendarSettings && (
<p className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("add_to_calendar")}
</p>
)}
{!useEventTypeDestinationCalendarEmail && {!useEventTypeDestinationCalendarEmail &&
verifiedSecondaryEmails.length > 0 && verifiedSecondaryEmails.length > 0 &&
!isTeamEventType && ( !isTeamEventType && (
@@ -411,6 +419,8 @@ export const EventAdvancedTab = ({
showBookerLayoutSelector, showBookerLayoutSelector,
customClassNames, customClassNames,
verifiedEmails, verifiedEmails,
orgId,
localeOptions,
}: EventAdvancedTabProps) => { }: EventAdvancedTabProps) => {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const platformContext = useAtomsContext(); const platformContext = useAtomsContext();
@@ -423,6 +433,9 @@ export const EventAdvancedTab = ({
!!formMethods.getValues("multiplePrivateLinks") && !!formMethods.getValues("multiplePrivateLinks") &&
formMethods.getValues("multiplePrivateLinks")?.length !== 0 formMethods.getValues("multiplePrivateLinks")?.length !== 0
); );
const [interfaceLanguageVisible, setInterfaceLanguageVisible] = useState(
!!formMethods.getValues("interfaceLanguage")
);
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl")); const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
const bookingFields: Prisma.JsonObject = {}; const bookingFields: Prisma.JsonObject = {};
@@ -457,6 +470,10 @@ export const EventAdvancedTab = ({
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD"; formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
const isRecurringEvent = !!formMethods.getValues("recurringEvent"); const isRecurringEvent = !!formMethods.getValues("recurringEvent");
const interfaceLanguageOptions =
localeOptions && localeOptions.length > 0
? [{ label: t("visitors_browser_language"), value: "" }, ...localeOptions]
: [];
const isRoundRobinEventType = const isRoundRobinEventType =
eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN; eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN;
@@ -481,11 +498,12 @@ export const EventAdvancedTab = ({
); );
}; };
const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps } = useLockedFieldsManager({ const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps, shouldLockIndicator } =
eventType, useLockedFieldsManager({
translate: t, eventType,
formMethods, translate: t,
}); formMethods,
});
const eventNamePlaceholder = getEventName({ const eventNamePlaceholder = getEventName({
...eventNameObject, ...eventNameObject,
eventName: formMethods.watch("eventName"), eventName: formMethods.watch("eventName"),
@@ -726,6 +744,69 @@ export const EventAdvancedTab = ({
/> />
)} )}
/> />
{!isPlatform && (
<Controller
name="autoTranslateDescriptionEnabled"
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
title={t("translate_description_button")}
checked={value}
onCheckedChange={(e) => onChange(e)}
disabled={!orgId}
tooltip={!orgId ? t("orgs_upgrade_to_enable_feature") : undefined}
data-testid="ai_translation_toggle"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
description={t("translate_description_button_description")}
/>
)}
/>
)}
{!isPlatform && (
<Controller
name="interfaceLanguage"
control={formMethods.control}
defaultValue={eventType.interfaceLanguage ?? ""}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
interfaceLanguageVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="event-interface-language-toggle"
title={t("interface_language")}
description={t("interface_language_description")}
checked={interfaceLanguageVisible}
{...shouldLockIndicator("interfaceLanguage")}
onCheckedChange={(e) => {
setInterfaceLanguageVisible(e);
if (!e) {
// disables the setting
formMethods.setValue("interfaceLanguage", null, { shouldDirty: true });
} else {
// "" is default value which means visitors browser language
formMethods.setValue("interfaceLanguage", "", { shouldDirty: true });
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<Select<{ label: string; value: string }>
data-testid="event-interface-language"
className="capitalize"
options={interfaceLanguageOptions}
onChange={(option) => {
onChange(option?.value);
}}
value={interfaceLanguageOptions.find((option) => option.value === value)}
/>
</div>
</SettingsToggle>
)}
/>
)}
<Controller <Controller
name="requiresBookerEmailVerification" name="requiresBookerEmailVerification"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
@@ -31,6 +31,8 @@ import {
} from "@calcom/ui/components/form"; } from "@calcom/ui/components/form";
import MaxActiveBookingsPerBookerController from "./MaxActiveBookingsPerBookerController"; import MaxActiveBookingsPerBookerController from "./MaxActiveBookingsPerBookerController";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { Icon } from "@calcom/ui/components/icon";
type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType]; type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType];
@@ -248,6 +250,7 @@ function RollingLimitRadioItem({
<span className="me-2 ms-2">&nbsp;{t("into_the_future")}</span> <span className="me-2 ms-2">&nbsp;{t("into_the_future")}</span>
</div> </div>
<div className="-ml-6 flex flex-col py-2"> <div className="-ml-6 flex flex-col py-2">
<div className="flex items-center">
<CheckboxField <CheckboxField
checked={!!rollingExcludeUnavailableDays} checked={!!rollingExcludeUnavailableDays}
disabled={isDisabled} disabled={isDisabled}
@@ -270,6 +273,15 @@ function RollingLimitRadioItem({
); );
}} }}
/> />
<Tooltip content={t("always_show_x_days_description", {
x: periodDaysWatch,
})}>
<Icon
name="info"
className="text-default hover:text-attention hover:bg-attention ms-1 inline h-4 w-4 rounded-md"
/>
</Tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -72,18 +72,12 @@ export const EventSetupTab = (
const formMethods = useFormContext<FormValues>(); const formMethods = useFormContext<FormValues>();
const { eventType, team, urlPrefix, hasOrgBranding, customClassNames, orgId } = props; const { eventType, team, urlPrefix, hasOrgBranding, customClassNames, orgId } = props;
const interfaceLanguageOptions =
props.localeOptions && props.localeOptions.length > 0
? [{ label: t("visitors_browser_language"), value: "" }, ...props.localeOptions]
: [];
const [multipleDuration, setMultipleDuration] = useState( const [multipleDuration, setMultipleDuration] = useState(
formMethods.getValues("metadata")?.multipleDuration formMethods.getValues("metadata")?.multipleDuration
); );
const [firstRender, setFirstRender] = useState(true); const [firstRender, setFirstRender] = useState(true);
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const autoTranslateDescriptionEnabled = formMethods.watch("autoTranslateDescriptionEnabled");
const multipleDurationOptions = [ const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480, 5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
@@ -161,48 +155,6 @@ export const EventSetupTab = (
</> </>
)} )}
</div> </div>
{!isPlatform && (
<div className="[&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("translate_description_button")}
checked={!!autoTranslateDescriptionEnabled}
onCheckedChange={(value) => {
formMethods.setValue("autoTranslateDescriptionEnabled", value, { shouldDirty: true });
}}
disabled={!orgId}
tooltip={!orgId ? t("orgs_upgrade_to_enable_feature") : undefined}
data-testid="ai_translation_toggle"
/>
</div>
)}
{!isPlatform && interfaceLanguageOptions.length > 0 && (
<div>
<Skeleton
as={Label}
loadingClassName="w-16"
htmlFor="interfaceLanguage"
className={customClassNames?.locationSection?.label}>
{t("interface_language")}
{shouldLockIndicator("interfaceLanguage")}
</Skeleton>
<Controller
name="interfaceLanguage"
control={formMethods.control}
defaultValue={eventType.interfaceLanguage ?? ""}
render={({ field: { value, onChange } }) => (
<Select<{ label: string; value: string }>
data-testid="event-interface-language"
className="capitalize"
options={interfaceLanguageOptions}
onChange={(option) => {
onChange(option?.value);
}}
value={interfaceLanguageOptions.find((option) => option.value === value)}
/>
)}
/>
</div>
)}
<TextField <TextField
required required
label={isPlatform ? "Slug" : t("URL")} label={isPlatform ? "Slug" : t("URL")}
@@ -362,7 +314,7 @@ export const EventSetupTab = (
{!lengthLockedProps.disabled && ( {!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal"> <div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle <SettingsToggle
title={t("allow_booker_to_select_duration")} title={t("allow_multiple_durations")}
checked={multipleDuration !== undefined} checked={multipleDuration !== undefined}
disabled={seatsEnabled} disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined} tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
@@ -953,7 +953,6 @@ function VariantFields({
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
fieldForm.setValue("variant", checked ? otherVariant : defaultVariant); fieldForm.setValue("variant", checked ? otherVariant : defaultVariant);
}} }}
classNames={{ container: "mt-2" }}
tooltip={t("Toggle Variant")} tooltip={t("Toggle Variant")}
/> />
) : ( ) : (
+232 -20
View File
@@ -1,10 +1,13 @@
# PBAC Refactoring Guide # PBAC Refactoring Guide
Quick guide for refactoring Cal.com handlers to use Permission-Based Access Control (PBAC) instead of membership-based queries. Quick guide for refactoring Cal.com to use Permission-Based Access Control (PBAC) instead of role-based checks and membership queries.
## Core Pattern: Team Filtering with PBAC ## Core Patterns
### 1. Team Filtering with PBAC (API/tRPC handlers)
**Before (Membership-based)**: **Before (Membership-based)**:
```typescript ```typescript
const teamsToQuery = ( const teamsToQuery = (
await prisma.membership.findMany({ await prisma.membership.findMany({
@@ -24,63 +27,272 @@ const teamsToQuery = (
``` ```
**After (PBAC-based)**: **After (PBAC-based)**:
```typescript ```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
const permissionCheckService = new PermissionCheckService(); const permissionCheckService = new PermissionCheckService();
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission( const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
ctx.user.id, userId: ctx.user.id,
"team.listMembers" permission: "team.listMembers",
); fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
```
### 2. UI Permission Checks (React components)
**Before (Role-based)**:
```typescript
// Client-side role check with role-based variable name
const isTeamAdminOrOwner = user?.isTeamAdminOrOwner ?? false;
const canSeeMembers = isTeamAdminOrOwner;
```
**After (PBAC-based)**:
```typescript
// Server-side permission check in page/layout
import { MembershipRole } from "@calcom/prisma/enums";
const permissionCheckService = new PermissionCheckService();
const teamIdsWithPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: session.user.id,
permission: "team.listMembers",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const permissions = {
canListMembers: teamIdsWithPermission.length > 0,
};
// Pass to component
<MyComponent permissions={permissions} />;
``` ```
## Key Points ## Key Points
### 1. Use Direct Permission Strings ### 1. Use Direct Permission Strings
- Use `"team.listMembers"` directly instead of `PermissionMapper.toPermissionString()` - Use `"team.listMembers"` directly instead of `PermissionMapper.toPermissionString()`
- Simpler and more readable - Simpler and more readable
### 2. No Fallback Logic Needed ### 2. No Fallback Logic Needed
- `getTeamIdsWithPermission()` handles errors internally - `getTeamIdsWithPermission()` handles errors internally
- Returns empty array `[]` when user has no permissions (this is legitimate) - Returns empty array `[]` when user has no permissions (this is legitimate)
- Don't assume empty array means PBAC failure - Don't assume empty array means PBAC failure
### 3. Common Team Permissions ### 3. Server-Side Permission Checking
- **Move permission checks from client to server** - crucial for security
- Check permissions in page/layout components, not in UI components
- Pass permission results as props to maintain separation of concerns
### 4. Permissions Object Pattern
- Wrap permission booleans in a `permissions` object for extensibility
- Use descriptive names like `canListMembers` instead of raw permission strings
- Makes it easy to add more permissions later
### 5. Permission-Based Variable Naming
- **Use permission-specific names** like `canListMembers` instead of role-based names like `isTeamAdminOrOwner`
- Follow the pattern: `can[Action][Resource]` (e.g., `canListMembers`, `canUpdateTeam`, `canInviteUsers`)
- Makes code more readable and aligns with the actual permission being checked
- Easier to understand what specific action is being permitted
### 6. Team vs Personal Resources
**Rule**: Use `teamId` to determine if permission checks are needed:
- `teamId` present → Team resource → Check permissions
- `teamId` null/undefined → Personal resource → Check ownership only
**Example**: `booking.read` permission
- ✅ Has permission: Can read **all team members' bookings**
- ❌ No permission: Can **still read own bookings**, just not others'
```typescript
if (resource.teamId) {
// Team resource - check permissions
const hasPermission = await permissionService.hasPermission(userId, "resource.read", resource.teamId);
if (!hasPermission) throw new ForbiddenError();
} else {
// Personal resource - check ownership only
if (resource.userId !== currentUserId) throw new ForbiddenError();
}
```
### 7. Common Team Permissions
- `"booking.read"` - Read all team members' bookings (not just own)
- `"team.listMembers"` - List team members - `"team.listMembers"` - List team members
- `"team.read"` - View team details - `"team.read"` - View team details
- `"team.update"` - Edit team settings - `"team.update"` - Edit team settings
- `"team.invite"` - Invite members - `"team.invite"` - Invite members
- `"team.remove"` - Remove members - `"team.remove"` - Remove members
## Step-by-Step Refactoring ## Step-by-Step Refactoring
1. **Add import**: ### For API/tRPC Handlers
```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; 1. **Add imports**:
```
```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
```
2. **Replace membership query**: 2. **Replace membership query**:
```typescript
const permissionCheckService = new PermissionCheckService(); ```typescript
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission( const permissionCheckService = new PermissionCheckService();
ctx.user.id, const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
"team.listMembers" userId: ctx.user.id,
); permission: "team.listMembers",
``` fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
```
3. **Keep existing privacy checks** - PBAC doesn't replace organization-level privacy logic 3. **Keep existing privacy checks** - PBAC doesn't replace organization-level privacy logic
## Example: listSimpleMembers.handler.ts ### For UI Components
1. **Move permission check to server** (page/layout component):
```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
// In your page component
const permissionCheckService = new PermissionCheckService();
const teamIdsWithPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: session.user.id,
permission: "team.listMembers",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const permissions = {
canListMembers: teamIdsWithPermission.length > 0, // Permission-specific name
};
```
2. **Pass permissions as props**:
```typescript
<BookingsContent permissions={permissions} />
```
3. **Update component interface**:
```typescript
interface BookingsProps {
permissions: {
canListMembers: boolean;
};
}
```
4. **Replace role checks in component**:
```typescript
// Replace: const isTeamAdminOrOwner = user?.isTeamAdminOrOwner ?? false
// With: const canListMembers = permissions.canListMembers
// Use permission-specific variable names throughout your component
```
## Examples
### API Handler: listSimpleMembers.handler.ts
**What changed**: **What changed**:
- Replaced 17 lines of membership query with 2 lines of PBAC - Replaced 17 lines of membership query with 2 lines of PBAC
- Removed unnecessary imports and fallback logic - Removed unnecessary imports and fallback logic
- Used direct permission string `"team.listMembers"` - Used direct permission string `"team.listMembers"`
**Result**: Cleaner, more maintainable code that respects fine-grained permissions. **Result**: Cleaner, more maintainable code that respects fine-grained permissions.
### UI Component: Bookings Page
**What changed** (from PR #24006):
- Moved permission check from client (`user?.isTeamAdminOrOwner`) to server
- Added `permissions` prop with `canListMembers` boolean
- Used `teamIdsWithPermission.length > 0` pattern for UI control
- Added E2E test to verify member filter visibility
**Before**:
```typescript
// In component - role-based variable name
const isTeamAdminOrOwner = user?.isTeamAdminOrOwner ?? false;
const canSeeMembers = isTeamAdminOrOwner;
```
**After**:
```typescript
// In page component (server-side)
const permissions = {
canListMembers: teamIdsWithPermission.length > 0,
};
// In UI component - permission-specific variable name
const canListMembers = permissions.canListMembers;
const canSeeMembers = canListMembers; // Use permission-specific name
```
**Result**: Secure server-side permission checking with clean UI separation.
## Verification ## Verification
### For All Refactoring
- Run `yarn type-check:ci --force` - Run `yarn type-check:ci --force`
- Ensure existing privacy checks remain intact - Ensure existing privacy checks remain intact
- Test with users who have different permission levels - Test with users who have different permission levels
### For UI Refactoring
- Add E2E tests to verify UI element visibility based on permissions
- Test with different user roles (ADMIN, OWNER, MEMBER)
- Verify that permission checks happen server-side, not client-side
- Confirm that UI elements are properly hidden/shown based on `permissions` props
### Common Pitfalls
- **Don't check permissions client-side** - always do it server-side for security
- **Don't assume empty array means failure** - it's valid when user has no permissions
- **Remember to update TypeScript interfaces** when adding new permission props
- **Don't use role-based variable names** - use permission-specific names instead
## Variable Naming Best Practices
### ✅ Good: Permission-Specific Names
```typescript
const canListMembers = permissions.canListMembers;
const canUpdateTeam = permissions.canUpdateTeam;
const canInviteUsers = permissions.canInviteUsers;
const canDeleteBookings = permissions.canDeleteBookings;
```
### ❌ Bad: Role-Based Names
```typescript
const isTeamAdminOrOwner = user?.isTeamAdminOrOwner;
const isAdmin = user?.role === "ADMIN";
const hasPermission = user?.isTeamAdminOrOwner;
```
### Naming Convention
- Use the pattern: `can[Action][Resource]`
- Examples: `canListMembers`, `canUpdateTeam`, `canInviteUsers`
- Makes code self-documenting and easier to understand
- Aligns variable names with the actual permissions being checked
@@ -10,6 +10,8 @@ export enum Resource {
RoutingForm = "routingForm", RoutingForm = "routingForm",
Workflow = "workflow", Workflow = "workflow",
Webhook = "webhook", Webhook = "webhook",
Availability = "availability",
OutOfOffice = "ooo",
} }
export enum CrudAction { export enum CrudAction {
@@ -114,6 +116,7 @@ export const isValidPermissionString = (val: unknown): val is PermissionString =
* @returns A new object without the _resource property * @returns A new object without the _resource property
*/ */
export const filterResourceConfig = (config: ResourceConfig): Omit<ResourceConfig, "_resource"> => { export const filterResourceConfig = (config: ResourceConfig): Omit<ResourceConfig, "_resource"> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _resource, ...rest } = config; const { _resource, ...rest } = config;
return rest; return rest;
}; };
@@ -556,4 +559,76 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
dependsOn: ["webhook.read"], dependsOn: ["webhook.read"],
}, },
}, },
[Resource.Availability]: {
_resource: {
i18nKey: "pbac_resource_availability",
},
[CrudAction.Create]: {
description: "Create availability",
category: "availability",
i18nKey: "pbac_action_create",
descriptionI18nKey: "pbac_desc_create_availability",
scope: [],
dependsOn: ["availability.read"],
},
[CrudAction.Read]: {
description: "View availability",
category: "availability",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_availability",
scope: [],
},
[CrudAction.Update]: {
description: "Update availability",
category: "availability",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_availability",
scope: [],
dependsOn: ["availability.read"],
},
[CrudAction.Delete]: {
description: "Delete availability",
category: "availability",
i18nKey: "pbac_action_delete",
descriptionI18nKey: "pbac_desc_delete_availability",
scope: [],
dependsOn: ["availability.read"],
},
},
[Resource.OutOfOffice]: {
_resource: {
i18nKey: "pbac_resource_out_of_office",
},
[CrudAction.Create]: {
description: "Create out of office",
category: "ooo",
i18nKey: "pbac_action_create",
descriptionI18nKey: "pbac_desc_create_out_of_office",
scope: [],
dependsOn: ["ooo.read"],
},
[CrudAction.Read]: {
description: "View out of office",
category: "ooo",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_out_of_office",
scope: [],
},
[CrudAction.Update]: {
description: "Update out of office",
category: "ooo",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_out_of_office",
scope: [],
dependsOn: ["ooo.read"],
},
[CrudAction.Delete]: {
description: "Delete out of office",
category: "ooo",
i18nKey: "pbac_action_delete",
descriptionI18nKey: "pbac_desc_delete_out_of_office",
scope: [],
dependsOn: ["ooo.read"],
},
},
}; };
@@ -1,6 +1,8 @@
import { isTeamOwner } from "@calcom/features/ee/teams/lib/queries";
import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums";
import { RoleManagementError, RoleManagementErrorCode } from "../domain/errors/role-management.error"; import { RoleManagementError, RoleManagementErrorCode } from "../domain/errors/role-management.error";
@@ -10,7 +12,13 @@ import { RoleService } from "./role.service";
interface IRoleManager { interface IRoleManager {
isPBACEnabled: boolean; isPBACEnabled: boolean;
checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void>; checkPermissionToChangeRole(
userId: number,
targetId: number,
scope: "org" | "team",
memberId?: number,
newRole?: MembershipRole | string
): Promise<void>;
assignRole( assignRole(
userId: number, userId: number,
organizationId: number, organizationId: number,
@@ -29,7 +37,17 @@ class PBACRoleManager implements IRoleManager {
private readonly permissionCheckService: PermissionCheckService private readonly permissionCheckService: PermissionCheckService
) {} ) {}
async checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void> { async checkPermissionToChangeRole(
userId: number,
targetId: number,
scope: "org" | "team",
// Not required for this instance
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_memberId?: number,
// Not required for this instance
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_newRole?: MembershipRole | string
): Promise<void> {
const hasPermission = await this.permissionCheckService.checkPermission({ const hasPermission = await this.permissionCheckService.checkPermission({
userId, userId,
teamId: targetId, teamId: targetId,
@@ -95,7 +113,69 @@ class PBACRoleManager implements IRoleManager {
class LegacyRoleManager implements IRoleManager { class LegacyRoleManager implements IRoleManager {
public isPBACEnabled = false; public isPBACEnabled = false;
async checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void> {
protected async validateRoleChange(
userId: number,
teamId: number,
memberId: number,
newRole: MembershipRole | string,
memberships: Membership[]
): Promise<void> {
// Only validate for traditional MembershipRole values
if (typeof newRole !== "string" || !Object.values(MembershipRole).includes(newRole as MembershipRole)) {
return;
}
const targetMembership = memberships.find((m) => m.userId === memberId);
const myMembership = memberships.find((m) => m.userId === userId);
const teamOwners = memberships.filter((m) => m.role === MembershipRole.OWNER);
const teamHasMoreThanOneOwner = teamOwners.length > 1;
if (!targetMembership) {
throw new RoleManagementError("Target membership not found", RoleManagementErrorCode.UNAUTHORIZED);
}
// Only owners can award owner role
if (newRole === MembershipRole.OWNER && !(await isTeamOwner(userId, teamId))) {
throw new RoleManagementError("Only owners can award owner role", RoleManagementErrorCode.UNAUTHORIZED);
}
// Admins cannot change the role of an owner
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
throw new RoleManagementError(
"You can not change the role of an owner if you are an admin.",
RoleManagementErrorCode.UNAUTHORIZED
);
}
// Cannot change the role of the only owner
if (targetMembership?.role === MembershipRole.OWNER && !teamHasMoreThanOneOwner) {
throw new RoleManagementError(
"You can not change the role of the only owner of a team.",
RoleManagementErrorCode.UNAUTHORIZED
);
}
// Admins cannot promote themselves to a higher role (except to MEMBER which is a demotion)
if (
myMembership?.role === MembershipRole.ADMIN &&
memberId === userId &&
newRole !== MembershipRole.MEMBER
) {
throw new RoleManagementError(
"You can not change yourself to a higher role.",
RoleManagementErrorCode.UNAUTHORIZED
);
}
}
async checkPermissionToChangeRole(
userId: number,
targetId: number,
scope: "org" | "team",
memberId?: number,
newRole?: MembershipRole | string
): Promise<void> {
let hasPermission = false; let hasPermission = false;
if (scope === "team") { if (scope === "team") {
const team = await prisma.membership.findFirst({ const team = await prisma.membership.findFirst({
@@ -118,12 +198,25 @@ class LegacyRoleManager implements IRoleManager {
RoleManagementErrorCode.UNAUTHORIZED RoleManagementErrorCode.UNAUTHORIZED
); );
} }
// Additional validation for team role changes in legacy mode
if (scope === "team" && memberId && newRole) {
const memberships = await prisma.membership.findMany({
where: {
teamId: targetId,
accepted: true,
},
});
await this.validateRoleChange(userId, targetId, memberId, newRole, memberships);
}
} }
async assignRole( async assignRole(
userId: number, userId: number,
organizationId: number, organizationId: number,
role: MembershipRole | string, role: MembershipRole | string,
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_membershipId: number _membershipId: number
): Promise<void> { ): Promise<void> {
await prisma.membership.update({ await prisma.membership.update({
@@ -139,6 +232,8 @@ class LegacyRoleManager implements IRoleManager {
}); });
} }
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getAllRoles(_organizationId: number): Promise<{ id: string; name: string }[]> { async getAllRoles(_organizationId: number): Promise<{ id: string; name: string }[]> {
return [ return [
{ id: MembershipRole.OWNER, name: "Owner" }, { id: MembershipRole.OWNER, name: "Owner" },
@@ -147,6 +242,8 @@ class LegacyRoleManager implements IRoleManager {
]; ];
} }
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getTeamRoles(_teamId: number): Promise<{ id: string; name: string }[]> { async getTeamRoles(_teamId: number): Promise<{ id: string; name: string }[]> {
return [ return [
{ id: MembershipRole.OWNER, name: "Owner" }, { id: MembershipRole.OWNER, name: "Owner" },
@@ -156,6 +253,8 @@ class LegacyRoleManager implements IRoleManager {
} }
} }
export { LegacyRoleManager };
export class RoleManagementFactory { export class RoleManagementFactory {
private static instance: RoleManagementFactory; private static instance: RoleManagementFactory;
private featuresRepository: FeaturesRepository; private featuresRepository: FeaturesRepository;
@@ -163,6 +262,8 @@ export class RoleManagementFactory {
private permissionCheckService: PermissionCheckService; private permissionCheckService: PermissionCheckService;
private constructor() { private constructor() {
// Not used but needed for DI
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(this.featuresRepository = new FeaturesRepository(prisma)), (this.roleService = new RoleService()); (this.featuresRepository = new FeaturesRepository(prisma)), (this.roleService = new RoleService());
this.permissionCheckService = new PermissionCheckService(); this.permissionCheckService = new PermissionCheckService();
} }
@@ -51,7 +51,7 @@ export function ScheduleListItem({
<div className="space-x-2 rtl:space-x-reverse"> <div className="space-x-2 rtl:space-x-reverse">
<span className="text-emphasis truncate font-medium">{schedule.name}</span> <span className="text-emphasis truncate font-medium">{schedule.name}</span>
{schedule.isDefault && ( {schedule.isDefault && (
<Badge variant="success" className="text-xs"> <Badge variant="gray" className="text-xs">
{t("default")} {t("default")}
</Badge> </Badge>
)} )}
@@ -26,12 +26,12 @@ export default function Header({
<div> <div>
<header <header
className={classNames( className={classNames(
"border-subtle mx-auto block justify-between sm:flex", "border-subtle mx-auto flex justify-between",
borderInShellHeader && "rounded-t-lg border px-4 py-6 sm:px-6", borderInShellHeader && "rounded-t-lg border px-4 py-6 sm:px-6",
borderInShellHeader === undefined && "mb-8 border-b pb-8" borderInShellHeader === undefined && "mb-8 border-b pb-8"
)}> )}>
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex w-full items-center justify-between gap-2">
<div className="flex w-full items-center"> <div className="flex items-center">
{backButton && ( {backButton && (
<a href="javascript:history.back()"> <a href="javascript:history.back()">
<Icon name="arrow-left" className="mr-7" /> <Icon name="arrow-left" className="mr-7" />
@@ -52,7 +52,7 @@ export default function Header({
)} )}
</div> </div>
</div> </div>
<div className={classNames("flex-shrink-0 sm:ms-auto", ctaClassName)}>{CTA}</div> <div className={classNames("flex-shrink-0", ctaClassName)}>{CTA}</div>
</div> </div>
</header> </header>
<Suspense fallback={<Icon name="loader" className="mx-auto my-5 animate-spin" />}>{children}</Suspense> <Suspense fallback={<Icon name="loader" className="mx-auto my-5 animate-spin" />}>{children}</Suspense>
@@ -1,4 +1,5 @@
import { TeamBilling } from "@calcom/features/ee/billing/teams"; import { TeamBilling } from "@calcom/features/ee/billing/teams";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember";
import { deleteDomain } from "@calcom/lib/domainManager/organization"; import { deleteDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
@@ -11,6 +12,7 @@ import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { randomBytes } from "crypto";
const log = logger.getSubLogger({ prefix: ["TeamService"] }); const log = logger.getSubLogger({ prefix: ["TeamService"] });
@@ -47,6 +49,56 @@ export type RemoveMemberResult = {
}; };
export class TeamService { export class TeamService {
static async createInvite(
teamId: number,
options?: { token?: string }
): Promise<{ token: string; inviteLink: string }> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { parentId: true, isOrganization: true },
});
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
const isOrganizationOrATeamInOrganization = !!(team.parentId || team.isOrganization);
if (options?.token) {
const existingToken = await prisma.verificationToken.findFirst({
where: {
token: options.token,
identifier: `invite-link-for-teamId-${teamId}`,
teamId,
},
});
if (!existingToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite token not found" });
return {
token: existingToken.token,
inviteLink: TeamService.buildInviteLink(existingToken.token, isOrganizationOrATeamInOrganization),
};
}
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: `invite-link-for-teamId-${teamId}`,
token,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
expiresInDays: 7,
teamId,
},
});
return {
token,
inviteLink: TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization),
};
}
private static buildInviteLink(token: string, isOrgContext: boolean): string {
const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`;
const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
return isOrgContext ? orgInviteLink : teamInviteLink;
}
/** /**
* Deletes a team and all its associated data in a safe, transactional order. * Deletes a team and all its associated data in a safe, transactional order.
* External, critical services like billing are handled first to prevent data inconsistencies. * External, critical services like billing are handled first to prevent data inconsistencies.
+6
View File
@@ -1,5 +1,11 @@
## 1.1.2 ## 1.1.2
## 1.10.0
### Minor Changes
- [#24001](https://github.com/calcom/cal.com/pull/24001) [`752cfa6`](https://github.com/calcom/cal.com/commit/752cfa6bf98a826e8f49a590c8b0e70c9007ebfc) Thanks [@ThyMinimalDev](https://github.com/ThyMinimalDev)! - Reduced bundle size by removing unnecessary types
## 1.9.0 ## 1.9.0
### Minor Changes ### Minor Changes
@@ -77,12 +77,16 @@ export const CreateEventTypePlatformWrapper = ({
const team = teams?.find((t) => t.id === teamId); const team = teams?.find((t) => t.id === teamId);
const { t } = useLocale(); const { t } = useLocale();
const permissions = {
canCreateEventType: team?.role === "ADMIN" || team?.role === "OWNER",
};
return teamId && team ? ( return teamId && team ? (
<AtomsWrapper customClassName={customClassNames?.atomsWrapper}> <AtomsWrapper customClassName={customClassNames?.atomsWrapper}>
<TeamEventTypeForm <TeamEventTypeForm
teamSlug={team?.slug} teamSlug={team?.slug}
teamId={teamId} teamId={teamId}
isTeamAdminOrOwner={team.role === "ADMIN" || team.role === "OWNER"} permissions={permissions}
urlPrefix="" urlPrefix=""
isPending={createTeamEventTypeQuery.isPending} isPending={createTeamEventTypeQuery.isPending}
form={form} form={form}
@@ -1,6 +1,7 @@
import type { EventAdvancedBaseProps } from "@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab"; import type { EventAdvancedBaseProps } from "@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab";
import { EventAdvancedTab } from "@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab"; import { EventAdvancedTab } from "@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { localeOptions } from "@calcom/lib/i18n";
const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => { const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
const connectedCalendarsQuery = trpc.viewer.calendars.connectedCalendars.useQuery(); const connectedCalendarsQuery = trpc.viewer.calendars.connectedCalendars.useQuery();
@@ -17,6 +18,7 @@ const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
}} }}
showBookerLayoutSelector={true} showBookerLayoutSelector={true}
verifiedEmails={verifiedEmails} verifiedEmails={verifiedEmails}
localeOptions={localeOptions}
/> />
); );
}; };
@@ -282,6 +282,7 @@ const EventTypeWeb = ({
user={user} user={user}
isUserLoading={isLoggedInUserPending} isUserLoading={isLoggedInUserPending}
showToast={showToast} showToast={showToast}
orgId={orgBranding?.id ?? null}
/> />
), ),
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />, instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"description": "Customizable UI components to integrate scheduling into your product.", "description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.", "authors": "Cal.com, Inc.",
"version": "1.9.0", "version": "1.10.0",
"scripts": { "scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch", "dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify", "build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify",
@@ -5,9 +5,9 @@ export class Disabled_2024_06_14 {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@ApiProperty({ @ApiProperty({
description: "Indicates if the option is disabled", description:
"Only acceptable value for the `disabled` property is `true`. It is used to reset the value of the property for which previously an object containing specific settings was passed.",
example: true, example: true,
default: false,
}) })
disabled!: true; disabled!: true;
} }
@@ -1,27 +0,0 @@
import { Prisma } from "@calcom/prisma/client";
export function eventTypeTimestampsExtension() {
return Prisma.defineExtension({
query: {
eventType: {
async create({ args, query }) {
const now = new Date();
args.data.createdAt = now;
return query(args);
},
async createMany({ args, query }) {
const now = new Date();
if (Array.isArray(args.data)) {
args.data = args.data.map((item) => ({
...item,
createdAt: now,
}));
} else {
args.data.createdAt = now;
}
return query(args);
},
},
},
});
}
-3
View File
@@ -2,7 +2,6 @@ import { PrismaClient, type Prisma } from "@calcom/prisma/client";
import { bookingIdempotencyKeyExtension } from "./extensions/booking-idempotency-key"; import { bookingIdempotencyKeyExtension } from "./extensions/booking-idempotency-key";
import { disallowUndefinedDeleteUpdateManyExtension } from "./extensions/disallow-undefined-delete-update-many"; import { disallowUndefinedDeleteUpdateManyExtension } from "./extensions/disallow-undefined-delete-update-many";
import { eventTypeTimestampsExtension } from "./extensions/event-type-timestamps";
import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users"; import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users";
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams"; import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
import { usageTrackingExtention } from "./extensions/usage-tracking"; import { usageTrackingExtention } from "./extensions/usage-tracking";
@@ -43,7 +42,6 @@ export const customPrisma = (options?: Prisma.PrismaClientOptions) =>
.$extends(excludeLockedUsersExtension()) .$extends(excludeLockedUsersExtension())
.$extends(excludePendingPaymentsExtension()) .$extends(excludePendingPaymentsExtension())
.$extends(bookingIdempotencyKeyExtension()) .$extends(bookingIdempotencyKeyExtension())
.$extends(eventTypeTimestampsExtension())
.$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient; .$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient;
// If any changed on middleware server restart is required // If any changed on middleware server restart is required
@@ -60,7 +58,6 @@ export const prisma: PrismaClient = baseClient
.$extends(excludeLockedUsersExtension()) .$extends(excludeLockedUsersExtension())
.$extends(excludePendingPaymentsExtension()) .$extends(excludePendingPaymentsExtension())
.$extends(bookingIdempotencyKeyExtension()) .$extends(bookingIdempotencyKeyExtension())
.$extends(eventTypeTimestampsExtension())
.$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient; .$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient;
// This prisma instance is meant to be used only for READ operations. // This prisma instance is meant to be used only for READ operations.
@@ -0,0 +1,2 @@
-- Do not allow members to read others' bookings in the team
delete from "RolePermission" where "roleId" = 'member_role' and resource = 'booking' and action = 'read';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."EventType" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,45 @@
-- Add availability permissions for admin role (create, read, update, delete)
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
('availability', 'create'),
('availability', 'read'),
('availability', 'update'),
('availability', 'delete')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
-- Add ooo permissions for admin role (create, read, update, delete)
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
('ooo', 'create'),
('ooo', 'read'),
('ooo', 'update'),
('ooo', 'delete')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
-- Add availability read permission for member role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'member_role', resource, action, NOW()
FROM (
VALUES
('availability', 'read')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
-- Add ooo read permission for member role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'member_role', resource, action, NOW()
FROM (
VALUES
('ooo', 'read')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
+3
View File
@@ -32,6 +32,9 @@
"zod": "^3.22.4", "zod": "^3.22.4",
"zod-prisma-types": "^3.2.4" "zod-prisma-types": "^3.2.4"
}, },
"devDependencies": {
"npm-run-all": "^4.1.5"
},
"main": "index.ts", "main": "index.ts",
"types": "index.d.ts", "types": "index.d.ts",
"files": [ "files": [
+1 -1
View File
@@ -244,7 +244,7 @@ model EventType {
bookingRequiresAuthentication Boolean @default(false) bookingRequiresAuthentication Boolean @default(false)
createdAt DateTime? createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt updatedAt DateTime? @updatedAt
@@unique([userId, slug]) @@unique([userId, slug])
@@ -1,7 +1,9 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { PrismaClient } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -59,9 +61,37 @@ export const getUserEventGroups = async ({ ctx, input }: GetByViewerOptions) =>
const profileProcessor = new ProfilePermissionProcessor(); const profileProcessor = new ProfilePermissionProcessor();
const profiles = profileProcessor.processProfiles(eventTypeGroups, teamPermissionsMap); const profiles = profileProcessor.processProfiles(eventTypeGroups, teamPermissionsMap);
const permissionCheckService = new PermissionCheckService();
const teamIdsToCheck = filteredEventTypeGroups
.map((group) => group.teamId)
.filter((teamId): teamId is number => teamId !== null && teamId !== undefined);
const teamPermissionChecks = teamIdsToCheck.map(async (teamId) => {
const canCreateEventType = await permissionCheckService.checkPermission({
userId: user.id,
teamId: teamId,
permission: "eventType.create",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
return {
teamId,
permissions: {
canCreateEventType,
},
};
});
const teamPermissionsArray = await Promise.all(teamPermissionChecks);
const teamPermissions = teamPermissionsArray.reduce((acc, item) => {
acc[item.teamId] = item.permissions;
return acc;
}, {} as Record<number, { canCreateEventType: boolean }>);
return { return {
eventTypeGroups: filteredEventTypeGroups, eventTypeGroups: filteredEventTypeGroups,
profiles, profiles,
teamPermissions,
}; };
}; };
@@ -58,7 +58,7 @@ export async function getUserHandler({ input, ctx }: AdminVerifyOptions) {
} }
// get requested user from database and ensure they are in the same organization // get requested user from database and ensure they are in the same organization
const [requestedUser, membership, teams] = await prisma.$transaction([ const [requestedUser, membership, teams] = await Promise.all([
prisma.user.findUnique({ prisma.user.findUnique({
where: { id: input.userId }, where: { id: input.userId },
select: { select: {
@@ -1,8 +1,6 @@
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory"; import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { isTeamOwner } from "@calcom/features/ee/teams/lib/queries";
import { TeamRepository } from "@calcom/lib/server/repository/team"; import { TeamRepository } from "@calcom/lib/server/repository/team";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -30,9 +28,15 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
// Create role manager for this organization/team // Create role manager for this organization/team
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId); const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
// Check permission to change roles // Check permission to change roles (includes legacy validation for LegacyRoleManager)
try { try {
await roleManager.checkPermissionToChangeRole(ctx.user.id, input.teamId, "team"); await roleManager.checkPermissionToChangeRole(
ctx.user.id,
input.teamId,
"team",
input.memberId,
input.role
);
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -40,59 +44,17 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
}); });
} }
// For traditional role checks, fall back to existing logic // Get the target membership for the assignRole method
if ( const targetMembership = await prisma.membership.findUnique({
typeof input.role === "string" &&
Object.values(MembershipRole).includes(input.role as MembershipRole)
) {
// Only owners can award owner role.
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const memberships = await prisma.membership.findMany({
where: { where: {
teamId: input.teamId, userId_teamId: { userId: input.memberId, teamId: input.teamId },
}, },
}); });
const targetMembership = memberships.find((m) => m.userId === input.memberId);
const myMembership = memberships.find((m) => m.userId === ctx.user.id);
const teamOwners = memberships.filter((m) => m.role === MembershipRole.OWNER);
const teamHasMoreThanOneOwner = teamOwners.length > 1;
if (!targetMembership) { if (!targetMembership) {
throw new TRPCError({ code: "NOT_FOUND", message: "Target membership not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Target membership not found" });
} }
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not change the role of an owner if you are an admin.",
});
}
if (targetMembership?.role === MembershipRole.OWNER && !teamHasMoreThanOneOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not change the role of the only owner of a team.",
});
}
// TODO(SEAN): Remove this logic once PBAC is rolled out as there is no concept of higher roles for custom roles. Only default.
if (
myMembership?.role === MembershipRole.ADMIN &&
input.memberId === ctx.user.id &&
typeof input.role === "string" &&
Object.values(MembershipRole).includes(input.role as MembershipRole) &&
input.role !== MembershipRole.MEMBER
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not change yourself to a higher role.",
});
}
// Use role manager to assign the role // Use role manager to assign the role
try { try {
await roleManager.assignRole(input.memberId, input.teamId, input.role, targetMembership.id); await roleManager.assignRole(input.memberId, input.teamId, input.role, targetMembership.id);
@@ -1,7 +1,5 @@
import { randomBytes } from "crypto";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -38,38 +36,8 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) =
if (!hasInvitePermission) throw new TRPCError({ code: "UNAUTHORIZED" }); if (!hasInvitePermission) throw new TRPCError({ code: "UNAUTHORIZED" });
const isOrganizationOrATeamInOrganization = !!(team.parentId || team.isOrganization); const result = await TeamService.createInvite(teamId, { token: input.token });
return result;
if (input.token) {
const existingToken = await prisma.verificationToken.findFirst({
where: { token: input.token, identifier: `invite-link-for-teamId-${teamId}`, teamId },
});
if (!existingToken) throw new TRPCError({ code: "NOT_FOUND" });
return {
token: existingToken.token,
inviteLink: await getInviteLink(existingToken.token, isOrganizationOrATeamInOrganization),
};
}
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: `invite-link-for-teamId-${teamId}`,
token,
expires: new Date(new Date().setHours(168)), // +1 week,
expiresInDays: 7,
teamId,
},
});
return { token, inviteLink: await getInviteLink(token, isOrganizationOrATeamInOrganization) };
}; };
async function getInviteLink(token = "", isOrgContext = false) {
const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`;
const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
if (isOrgContext) return orgInviteLink;
return teamInviteLink;
}
export default createInviteHandler; export default createInviteHandler;
@@ -1,5 +1,7 @@
import { isTeamOwner } from "@calcom/features/ee/teams/lib/queries"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamService } from "@calcom/lib/server/service/teamService"; import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -14,7 +16,26 @@ type DeleteOptions = {
}; };
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); const team = await prisma.team.findUnique({
where: {
id: input.teamId,
},
select: {
isOrganization: true,
},
});
if (!team) throw new TRPCError({ code: "NOT_FOUND" });
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: input.teamId,
permission: team.isOrganization ? "organization.delete" : "team.delete",
fallbackRoles: [MembershipRole.OWNER],
});
if (!hasPermission) throw new TRPCError({ code: "FORBIDDEN" });
return await TeamService.delete({ id: input.teamId }); return await TeamService.delete({ id: input.teamId });
}; };
@@ -492,7 +492,7 @@ export async function sendSignupToOrganizationEmail({
data: { data: {
identifier: usernameOrEmail, identifier: usernameOrEmail,
token, token,
expires: new Date(new Date().setHours(168)), // +1 week expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: { team: {
connect: { connect: {
id: teamId, id: teamId,
@@ -717,7 +717,7 @@ export const sendExistingUserTeamInviteEmails = async ({
data: { data: {
identifier: user.email, identifier: user.email,
token, token,
expires: new Date(new Date().setHours(168)), // +1 week expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: { team: {
connect: { connect: {
id: teamId, id: teamId,
@@ -74,7 +74,14 @@ type AddonProps = {
position?: "start" | "end"; position?: "start" | "end";
}; };
const Addon = ({ children, className, error, onClickAddon, size = "md", position = "start" }: AddonProps) => ( const Addon = ({
children,
className,
error,
onClickAddon,
size: _size = "md",
position: _position = "start",
}: AddonProps) => (
<div <div
onClick={onClickAddon && onClickAddon} onClick={onClickAddon && onClickAddon}
className={classNames( className={classNames(
@@ -208,6 +215,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
size={size} size={size}
className={classNames( className={classNames(
className, className,
type === "email" && "focus:border-subtle focus:ring-brand-default focus:ring-2",
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed" "disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed"
)} )}
{...passThrough} {...passThrough}
@@ -62,7 +62,7 @@ const VerticalTabItem = ({
target={props.isExternalLink ? "_blank" : "_self"} target={props.isExternalLink ? "_blank" : "_self"}
className={classNames( className={classNames(
props.textClassNames || "text-default text-sm font-medium leading-none", props.textClassNames || "text-default text-sm font-medium leading-none",
"hover:bg-subtle [&[aria-current='page']]:bg-subtle [&[aria-current='page']]:text-emphasis group-hover:text-default group flex w-full flex-row items-center rounded-md p-2 transition", "hover:bg-subtle [&[aria-current='page']]:bg-subtle [&[aria-current='page']]:text-emphasis group-hover:text-default group flex w-full flex-row items-center rounded-md p-2 transition ",
props.disabled && "pointer-events-none !opacity-30", props.disabled && "pointer-events-none !opacity-30",
(isChild || !props.icon) && "ml-7", (isChild || !props.icon) && "ml-7",
props.className props.className
-1
View File
@@ -1,7 +1,6 @@
{ {
"$schema": "https://turborepo.org/schema.json", "$schema": "https://turborepo.org/schema.json",
"globalDependencies": ["yarn.lock"], "globalDependencies": ["yarn.lock"],
"ui": "tui",
"globalEnv": [ "globalEnv": [
"ALLOWED_HOSTNAMES", "ALLOWED_HOSTNAMES",
"ANALYZE", "ANALYZE",
+1
View File
@@ -3848,6 +3848,7 @@ __metadata:
"@prisma/client": ^6.7.0 "@prisma/client": ^6.7.0
"@prisma/extension-accelerate": ^1.3.0 "@prisma/extension-accelerate": ^1.3.0
"@prisma/generator-helper": ^6.7.0 "@prisma/generator-helper": ^6.7.0
npm-run-all: ^4.1.5
prisma: ^6.7.0 prisma: ^6.7.0
prisma-kysely: 2.2.0 prisma-kysely: 2.2.0
ts-node: ^10.9.1 ts-node: ^10.9.1