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
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/api/**/* @calcom/API
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @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
secrets: inherit
e2e-atoms:
name: Tests
needs: [lint, build, build-atoms, build-api-v2]
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
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()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
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:
runs-on: ubuntu-latest
+2 -10
View File
@@ -195,13 +195,6 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
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:
name: Analyze Build
needs: [build]
@@ -211,7 +204,7 @@ jobs:
merge-reports:
name: Merge reports
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
secrets: inherit
@@ -255,8 +248,7 @@ jobs:
e2e-api-v2,
e2e-embed,
e2e-embed-react,
e2e-app-store,
e2e-atoms,
e2e-app-store
]
if: always()
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.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 { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
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 { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipsController,
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
OrganizationsTeamsInviteController,
OrganizationsAttributesController,
OrganizationsAttributesOptionsController,
OrganizationsWebhooksController,
@@ -68,7 +68,7 @@ export class ManagedOrganizationsRepository {
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.findMany({
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 { 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";
@@ -33,9 +35,30 @@ const Page = async ({ params }: PageProps) => {
const t = await getTranslate();
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 (
<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>
);
};
@@ -11,6 +11,7 @@ export const TeamsCTA = () => {
data-testid="new-team-btn"
variant="fab"
StartIcon="plus"
size="sm"
type="button"
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")}
@@ -438,6 +438,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
)}
@@ -445,6 +446,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
{/* Show roles only for sub-teams with PBAC-enabled parent */}
@@ -465,6 +467,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
{/* Hide if there is a parent ID */}
@@ -474,6 +477,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
</>
@@ -482,6 +486,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
</>
@@ -643,6 +648,7 @@ const SettingsSidebarContainer = ({
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
icon="plus"
disableChevron
/>
@@ -744,12 +750,14 @@ const SettingsSidebarContainer = ({
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto !px-2"
disableChevron
/>
<>
@@ -1,6 +1,16 @@
import type { PageProps as ServerPageProps } from "app/_types";
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 }> }) =>
await _generateMetadata(
@@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/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 (
<LayoutWrapper>
<CreateTeamEventType />
<CreateTeamEventType permissions={{ canCreateEventType }} />
</LayoutWrapper>
);
};
@@ -213,7 +213,6 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
);
const [submit, setSubmit] = useState(false);
const [mounted, setMounted] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
useEffect(() => {
@@ -223,11 +222,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
}
}, [submit, allUpdated, mainForSubmitRef]);
useEffect(() => {
setMounted(true);
}, []);
if (!formPortalRef?.current || !mounted) {
if (!formPortalRef?.current) {
return null;
}
@@ -81,7 +81,7 @@ describe("EditLocationDialog", () => {
},
{
value: "integrations:daily",
label: "Cal Video (Global)",
label: "Cal Video (Default)",
disabled: false,
icon: "/app-store/dailyvideo/icon.svg",
slug: "daily-video",
@@ -310,7 +310,6 @@ const OnboardingPage = ({
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
formPortalRef.current &&
eventTypeGroups && (
<ConfigureStepCard
slug={appMetadata.slug}
@@ -57,6 +57,9 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
type BookingsProps = {
status: (typeof validStatuses)[number];
userId?: number;
permissions: {
canReadOthersBookings: boolean;
};
};
function useSystemSegments(userId?: number) {
@@ -111,7 +114,7 @@ type RowData =
type: "today" | "next";
};
function BookingsContent({ status }: BookingsProps) {
function BookingsContent({ status, permissions }: BookingsProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -154,7 +157,7 @@ function BookingsContent({ status }: BookingsProps) {
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
"data-testid": tabConfig["data-testid"],
}));
}, [searchParams?.toString()]);
}, [searchParams]);
const eventTypeIds = useFilterValue("eventTypeId", 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, {
id: "userId",
header: t("member"),
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
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]);
@@ -352,7 +355,7 @@ function BookingsContent({ status }: BookingsProps) {
isToday: false,
})) || []
);
}, [query.data]);
}, [query.data, status, user?.timeZone]);
const bookingsToday = useMemo<RowData[]>(() => {
return (
@@ -371,7 +374,7 @@ function BookingsContent({ status }: BookingsProps) {
isToday: true,
})) ?? []
);
}, [query.data]);
}, [query.data, user?.timeZone]);
const finalData = useMemo<RowData[]>(() => {
if (status !== "upcoming") {
@@ -556,7 +556,9 @@ export default function Success(props: PageProps) {
<div className="font-medium">
{isCancelled ? t("reason") : t("reschedule_reason")}
</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 && (
@@ -12,7 +12,10 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
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 { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
@@ -846,17 +849,7 @@ const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTe
);
};
const CTA = ({
profileOptions,
}: {
profileOptions: {
teamId: number | null | undefined;
label: string | null;
image: string;
membershipRole: MembershipRole | null | undefined;
slug: string | null;
}[];
}) => {
const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => {
const { t } = useLocale();
if (!profileOptions.length) return null;
@@ -951,7 +944,7 @@ type Props = {
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
const profileOptions =
userEventGroupsData?.profiles
userEventGroupsData.profiles
?.filter((profile) => !profile.readOnly)
?.filter((profile) => !profile.eventTypesLockedByOrg)
?.filter((profile) => {
@@ -973,12 +966,20 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
);
})
?.map((profile) => {
const permissions = profile.teamId
? userEventGroupsData.teamPermissions[profile.teamId]
: {
// always can create eventType on personal level
canCreateEventType: true,
};
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
permissions,
};
}) ?? [];
@@ -8,11 +8,15 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { WizardLayout } from "@calcom/ui/components/layout";
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 { t } = useLocale();
const router = useRouter();
@@ -53,7 +57,7 @@ export const CreateTeamEventType = () => {
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={true}
permissions={permissions}
urlPrefix={urlPrefix}
isPending={createMutation.isPending}
form={form}
@@ -82,5 +86,3 @@ export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
</WizardLayout>
);
};
export default CreateTeamEventType;
+3 -2
View File
@@ -426,6 +426,7 @@ export default function Signup({
id="signup-email"
{...register("email")}
label={t("email")}
placeholder="john@doe.com"
type="email"
autoComplete="email"
disabled={prepopulateFormValues?.email}
@@ -522,8 +523,8 @@ export default function Signup({
usernameTaken
}>
{premiumUsername && !usernameTaken
? `${t("create_account")} (${getPremiumPlanPriceValue()})`
: t("create_account")}
? `${t("get_started")} (${getPremiumPlanPriceValue()})`
: t("get_started")}
</Button>
)}
</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 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 gotoBookingPage(page);
@@ -282,7 +282,7 @@ test.describe("Event Types tests", () => {
await selectAttendeePhoneNumber(page);
// Add Cal Video location
await addAnotherLocation(page, "Cal Video (Global)");
await addAnotherLocation(page, "Cal Video (Default)");
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 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
.getByTestId("event-interface-language")
.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 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.locator(`text="Deutsch"`).click();
await saveEventType(page);
@@ -574,6 +580,9 @@ test.describe("Event Types tests", () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
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("select-option-es").click();
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)
const segmentSelect = page.getByTestId("filter-segment-select");
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);
},
getAITranslationToggleDisabled: async () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
timeout: 5000,
state: "attached",
@@ -207,6 +208,7 @@ export function createBookingPageFixture(page: Page) {
return (await toggle.getAttribute("disabled")) !== null;
},
toggleAITranslation: async () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.getByTestId("ai_translation_toggle").click();
},
updateEventTypeDescription: async (description: string) => {
@@ -81,10 +81,13 @@ test.describe("Managed Event Types", () => {
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")).toContainText("Basics"); //fix the race condition
await page.locator("#location-select").click();
const optionText = await getByKey(page, "organizer_default_conferencing_app");
await expect(optionText).toBeVisible();
await optionText.click();
const targetText = (await localize("en"))("organizer_default_conferencing_app");
const options = page.locator('[data-testid="location-select-item-conferencing"]');
const targetOption = options.locator(`text=${targetText}`);
await expect(targetOption).toBeVisible();
await targetOption.click();
await saveAndWaitForResponse(page);
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> }[] = [
{ slug: "setup", locator: (page) => getByKey(page, "translate_description_button") },
{ slug: "setup", locator: (page) => getByKey(page, "allow_multiple_durations") },
{
slug: "team",
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: {
identifier: userToCreate.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
create: {
name: "Rick's Team",
+23 -7
View File
@@ -321,6 +321,7 @@
"secret_meeting": "Secret Meeting",
"login_instead": "Login instead",
"create_account": "Create Account",
"get_started": "Get started",
"confirm_password": "Confirm password",
"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.",
@@ -958,7 +959,7 @@
"light_event_type_color": "Event Type Color (Light Theme)",
"dark_event_type_color": "Event Type Color (Dark Theme)",
"file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create Team",
"create_team": "Create team",
"name": "Name",
"nameless_team": "Nameless Team",
"oauth_clients": "OAuth Clients",
@@ -1019,6 +1020,7 @@
"attendees": "Attendees",
"add_attendees": "Add attendees",
"show_advanced_settings": "Show advanced settings",
"hide_advanced_settings": "Hide advanced settings",
"event_name": "Event Name",
"event_name_in_calendar": "Calendar event name",
"event_name_tooltip": "The name that will appear in calendars",
@@ -1231,7 +1233,7 @@
"contact_sales": "Contact Sales",
"error_404": "Error 404",
"default": "Default",
"set_to_default": "Set to Default",
"set_to_default": "Set as Default",
"new_schedule_btn": "New schedule",
"add_new_schedule": "Add a new schedule",
"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.",
"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.",
"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.",
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "Click here to stop",
@@ -2689,7 +2691,7 @@
"enterprise": "Enterprise",
"enterprise_description": "Upgrade to Enterprise to 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_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",
@@ -2923,6 +2925,7 @@
"proceeding_agreement": "By proceeding, you agree to our",
"terms": "Terms",
"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_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",
@@ -3144,7 +3147,8 @@
"filter_operator_ends_with": "Ends with",
"filter_operator_is_empty": "Is 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_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"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.",
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
"segment": "Segment",
"saved_filters": "Saved filters",
"rename": "Rename",
"rename_segment": "Rename Segment",
"delete_segment": "Delete Segment",
@@ -3339,6 +3344,7 @@
"picklist": "Picklist",
"most_cancelled_bookings": "Most Cancelled",
"interface_language": "Interface Language",
"interface_language_description": "Set your preferred language for the booking interface",
"visitors_browser_language": "Visitor's browser language",
"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",
@@ -3510,10 +3516,20 @@
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_resource_webhook": "Webhook",
"pbac_resource_availability": "Availability",
"pbac_resource_out_of_office": "Out of Office",
"pbac_desc_create_webhooks": "Create webhooks",
"pbac_desc_view_webhooks": "View webhooks",
"pbac_desc_update_webhooks": "Update 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_create_event_types": "Create 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",
"round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time",
"meeting_start_time": "Meeting Start Time",
"booking_creation_time": "Booking creation time",
"meeting_start_time": "Meeting start time",
"members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"download_expense_log": "Download Expense Log",
+95 -3
View File
@@ -4421,6 +4421,70 @@
"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": {
"get": {
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -15219,9 +15283,8 @@
"properties": {
"disabled": {
"type": "boolean",
"description": "Indicates if the option is disabled",
"example": true,
"default": false
"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
}
},
"required": ["disabled"]
@@ -19915,6 +19978,35 @@
},
"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": {
"type": "object",
"properties": {
+2 -1
View File
@@ -213,7 +213,8 @@
"platform/atoms/event-type",
"platform/atoms/calendar-settings",
"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
height="315"
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"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
@@ -1,4 +1,5 @@
import logger from "@calcom/lib/logger";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -23,25 +24,53 @@ export class OrganizationBillingPortalService extends BillingPortalService {
});
}
async getCustomerId(teamId: number): Promise<string | null> {
const log = logger.getSubLogger({ prefix: ["OrganizationBillingPortalService", "getCustomerId"] });
const team = await this.teamRepository.findById({ id: teamId });
if (!team) return null;
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
async getValidatedTeamSubscriptionId(metadata: Prisma.JsonValue) {
const teamMetadataParsed = teamMetadataSchema.safeParse(metadata);
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
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 {
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
const subscription = await getSubscriptionFromId(teamSubscriptionId);
if (!subscription?.customer) {
log.warn("Subscription found but no customer ID", {
teamId,
subscriptionId: teamMetadataParsed.data.subscriptionId,
subscriptionId: teamSubscriptionId,
});
return null;
}
+1 -1
View File
@@ -68,7 +68,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
delegatedToId: null,
delegationCredentialId: null,
team: {
name: "Global",
name: "Default",
},
};
logger.debug(
+2 -1
View File
@@ -36,6 +36,7 @@ export default function generateIcsFile({
status,
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",
"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>
))}
</ul>
@@ -19,6 +19,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import type { ButtonProps } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
interface AppCardProps {
app: App;
@@ -119,14 +120,14 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
</div> */}
<p
className="text-default mt-2 flex-grow text-sm"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(app.description) }}
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: "3",
}}>
{app.description}
</p>
}}/>
<div className="mt-5 flex max-w-full flex-row justify-between gap-2">
<Button
@@ -74,15 +74,15 @@ function ColumnVisibilityButtonComponent<TData>(
})}
</CommandGroup>
</CommandList>
<CommandSeparator />
<CommandSeparator className="mb-0" />
<CommandGroup>
<CommandItem
onSelect={() => {
allColumns.forEach((column) => column.toggleVisibility(true));
}}
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")}
</CommandItem>
@@ -155,11 +155,11 @@ export function FilterSegmentSelect() {
StartIcon="list-filter"
EndIcon="chevron-down"
data-testid="filter-segment-select">
{selectedSegment?.name || t("segment")}
{selectedSegment?.name || t("saved_filters")}
</Button>
</DropdownMenuTrigger>
<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.map((group, index) => (
@@ -157,7 +157,7 @@ export default function ApiKeyDialogForm({
{defaultValues ? t("edit_api_key") : t("create_api_key")}
</h2>
{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">
{t("api_key_modal_subtitle")}
</div>
@@ -178,7 +178,7 @@ export default function ApiKeyDialogForm({
<Controller
name="note"
control={form.control}
render={({ field: { onChange, value } }) => (
render={({ field: { value } }) => (
<TextField
name="note"
label={t("personal_note")}
@@ -211,7 +211,7 @@ export default function ApiKeyDialogForm({
</div>
<Controller
name="expiresAt"
render={({ field: { onChange, value } }) => {
render={({ field: { onChange } }) => {
const defaultValue = expiresAtOptions[1];
return (
@@ -45,7 +45,7 @@ const MakeTeamPrivateSwitch = ({
setTeamPrivate(checked);
mutation.mutate({ id: teamId, isPrivate: checked });
}}
switchContainerClassName="my-6"
switchContainerClassName="mt-6"
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";
type props = {
isTeamAdminOrOwner: boolean;
permissions: {
canCreateEventType: boolean;
};
teamSlug?: string | null;
teamId: number;
isPending: boolean;
@@ -25,7 +27,7 @@ type props = {
SubmitButton: (isPending: boolean) => ReactNode;
};
export const TeamEventTypeForm = ({
isTeamAdminOrOwner,
permissions,
teamSlug,
teamId,
form,
@@ -40,6 +42,7 @@ export const TeamEventTypeForm = ({
const { t } = useLocale();
const { register, setValue, formState } = form;
const { canCreateEventType } = permissions;
return (
<Form form={form} handleSubmit={handleSubmit}>
@@ -124,29 +127,29 @@ export const TeamEventTypeForm = ({
onValueChange={(val: SchedulingType) => {
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
{...register("schedulingType")}
value={SchedulingType.COLLECTIVE}
className={classNames("w-full text-sm", !isTeamAdminOrOwner && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}>
className={classNames("w-full text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(canCreateEventType && "w-full") }}>
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.ROUND_ROBIN}
className={classNames("text-sm", !isTeamAdminOrOwner && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}>
className={classNames("text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(canCreateEventType && "w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
{isTeamAdminOrOwner && (
{canCreateEventType && (
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.MANAGED}
className={classNames("text-sm", !isTeamAdminOrOwner && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}
className={classNames("text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(canCreateEventType && "w-full") }}
data-testid="managed-event-type">
<strong className="mb-1 block">{t("managed_event")}</strong>
<p>{t("managed_event_description")}</p>
@@ -229,7 +229,7 @@ export default function WorkflowListPage({ workflows }: Props) {
<div>
<div className="hidden md:block">
{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
alt={workflow.team?.name || ""}
href={
@@ -2,7 +2,6 @@ import { isValidPhoneNumber } from "libphonenumber-js";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm";
@@ -28,6 +27,17 @@ export interface EventTypeParent {
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(
z.object({
locationType: z.string(),
@@ -55,16 +65,7 @@ const querySchema = z.object({
.optional(),
});
export default function CreateEventTypeDialog({
profileOptions,
}: {
profileOptions: {
teamId: number | null | undefined;
label: string | null;
image: string | undefined;
membershipRole: MembershipRole | null | undefined;
}[];
}) {
export function CreateEventTypeDialog({ profileOptions }: { profileOptions: ProfileOption[] }) {
const { t } = useLocale();
const router = useRouter();
const orgBranding = useOrgBranding();
@@ -75,7 +76,7 @@ export default function CreateEventTypeDialog({
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) => {
router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`);
@@ -124,7 +125,7 @@ export default function CreateEventTypeDialog({
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={isTeamAdminOrOwner}
permissions={permissions}
urlPrefix={urlPrefix}
isPending={createMutation.isPending}
form={form}
@@ -1,7 +1,7 @@
import dynamic from "next/dynamic";
export { default as CheckedTeamSelect } from "./CheckedTeamSelect";
export { default as CreateEventTypeDialog } from "./CreateEventTypeDialog";
export { CreateEventTypeDialog } from "./CreateEventTypeDialog";
export { default as EventTypeDescription } from "./EventTypeDescription";
export { MultiplePrivateLinksController } from "./MultiplePrivateLinksController";
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 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 { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const isPlatform = useIsPlatform();
const [isExpanded, setIsExpanded] = useState(false);
const [parent] = useAutoAnimate<HTMLDivElement>();
return (
<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 />}
<>
<Tooltip content="expandable" side="right" className="lg:hidden">
<button
aria-expanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
className={classNames(
"todesktop:py-[7px] text-default group flex w-full items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
"[&[aria-current='page']]:!bg-transparent",
"[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
)}>
<span className="hidden w-full justify-between truncate text-ellipsis lg:flex">
{!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
name="calVideoSettings.disableRecordingForOrganizer"
defaultValue={!!calVideoSettings?.disableRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
<Controller
name="calVideoSettings.disableRecordingForOrganizer"
defaultValue={!!calVideoSettings?.disableRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.enableAutomaticRecordingForOrganizer"
defaultValue={!!calVideoSettings?.enableAutomaticRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_recording")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
{!isPlatform && (
<Controller
name="calVideoSettings.enableAutomaticRecordingForOrganizer"
defaultValue={!!calVideoSettings?.enableAutomaticRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_recording")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
);
}}
/>
)}
)}
<Controller
name="calVideoSettings.enableAutomaticTranscription"
defaultValue={!!calVideoSettings?.enableAutomaticTranscription}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_transcription")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
<Controller
name="calVideoSettings.enableAutomaticTranscription"
defaultValue={!!calVideoSettings?.enableAutomaticTranscription}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_transcription")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForGuests"
defaultValue={!!calVideoSettings?.disableTranscriptionForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForGuests"
defaultValue={!!calVideoSettings?.disableTranscriptionForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
);
}}
/>
)}
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForOrganizer"
defaultValue={!!calVideoSettings?.disableTranscriptionForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
)}
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForOrganizer"
defaultValue={!!calVideoSettings?.disableTranscriptionForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
);
}}
/>
)}
)}
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-4"
className="leading-6"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})}
/>
<ErrorMessage
errors={formMethods.formState.errors?.calVideoSettings}
name="redirectUrlOnExit"
className={classNames("text-error text-sm")}
as="div"
id="calVideoSettings.redirectUrlOnExit-error"
/>
</LocationSettingsContainer>
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-4"
className="leading-6"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})}
/>
<ErrorMessage
errors={formMethods.formState.errors?.calVideoSettings}
name="redirectUrlOnExit"
className={classNames("text-error text-sm")}
as="div"
id="calVideoSettings.redirectUrlOnExit-error"
/>
</LocationSettingsContainer>
)}
</div>
</>
);
};
@@ -60,6 +60,7 @@ import {
CheckboxField,
Switch,
SettingsToggle,
Select,
} from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
@@ -120,6 +121,7 @@ export type EventAdvancedBaseProps = Pick<EventTypeSetupProps, "eventType" | "te
>;
isUserLoading?: boolean;
showToast: (message: string, variant: "success" | "warning" | "error") => void;
orgId: number | null;
customClassNames?: EventAdvancedTabCustomClassNames;
};
@@ -130,6 +132,7 @@ export type EventAdvancedTabProps = EventAdvancedBaseProps & {
error: unknown;
};
showBookerLayoutSelector: boolean;
localeOptions?: { value: string; label: string }[];
verifiedEmails?: string[];
};
@@ -253,6 +256,11 @@ const destinationCalendarComponents = {
/>
</div>
)}
{!showConnectedCalendarSettings && (
<p className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("add_to_calendar")}
</p>
)}
{!useEventTypeDestinationCalendarEmail &&
verifiedSecondaryEmails.length > 0 &&
!isTeamEventType && (
@@ -411,6 +419,8 @@ export const EventAdvancedTab = ({
showBookerLayoutSelector,
customClassNames,
verifiedEmails,
orgId,
localeOptions,
}: EventAdvancedTabProps) => {
const isPlatform = useIsPlatform();
const platformContext = useAtomsContext();
@@ -423,6 +433,9 @@ export const EventAdvancedTab = ({
!!formMethods.getValues("multiplePrivateLinks") &&
formMethods.getValues("multiplePrivateLinks")?.length !== 0
);
const [interfaceLanguageVisible, setInterfaceLanguageVisible] = useState(
!!formMethods.getValues("interfaceLanguage")
);
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
const bookingFields: Prisma.JsonObject = {};
@@ -457,6 +470,10 @@ export const EventAdvancedTab = ({
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
const isRecurringEvent = !!formMethods.getValues("recurringEvent");
const interfaceLanguageOptions =
localeOptions && localeOptions.length > 0
? [{ label: t("visitors_browser_language"), value: "" }, ...localeOptions]
: [];
const isRoundRobinEventType =
eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN;
@@ -481,11 +498,12 @@ export const EventAdvancedTab = ({
);
};
const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps } = useLockedFieldsManager({
eventType,
translate: t,
formMethods,
});
const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps, shouldLockIndicator } =
useLockedFieldsManager({
eventType,
translate: t,
formMethods,
});
const eventNamePlaceholder = getEventName({
...eventNameObject,
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
name="requiresBookerEmailVerification"
render={({ field: { value, onChange } }) => (
@@ -31,6 +31,8 @@ import {
} from "@calcom/ui/components/form";
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];
@@ -248,6 +250,7 @@ function RollingLimitRadioItem({
<span className="me-2 ms-2">&nbsp;{t("into_the_future")}</span>
</div>
<div className="-ml-6 flex flex-col py-2">
<div className="flex items-center">
<CheckboxField
checked={!!rollingExcludeUnavailableDays}
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>
@@ -72,18 +72,12 @@ export const EventSetupTab = (
const formMethods = useFormContext<FormValues>();
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(
formMethods.getValues("metadata")?.multipleDuration
);
const [firstRender, setFirstRender] = useState(true);
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const autoTranslateDescriptionEnabled = formMethods.watch("autoTranslateDescriptionEnabled");
const multipleDurationOptions = [
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>
{!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
required
label={isPlatform ? "Slug" : t("URL")}
@@ -362,7 +314,7 @@ export const EventSetupTab = (
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
title={t("allow_multiple_durations")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
@@ -953,7 +953,6 @@ function VariantFields({
onCheckedChange={(checked) => {
fieldForm.setValue("variant", checked ? otherVariant : defaultVariant);
}}
classNames={{ container: "mt-2" }}
tooltip={t("Toggle Variant")}
/>
) : (
+232 -20
View File
@@ -1,10 +1,13 @@
# 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)**:
```typescript
const teamsToQuery = (
await prisma.membership.findMany({
@@ -24,63 +27,272 @@ const teamsToQuery = (
```
**After (PBAC-based)**:
```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
const permissionCheckService = new PermissionCheckService();
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission(
ctx.user.id,
"team.listMembers"
);
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
userId: ctx.user.id,
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
### 1. Use Direct Permission Strings
- Use `"team.listMembers"` directly instead of `PermissionMapper.toPermissionString()`
- Simpler and more readable
### 2. No Fallback Logic Needed
- `getTeamIdsWithPermission()` handles errors internally
- Returns empty array `[]` when user has no permissions (this is legitimate)
- 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.read"` - View team details
- `"team.read"` - View team details
- `"team.update"` - Edit team settings
- `"team.invite"` - Invite members
- `"team.remove"` - Remove members
## Step-by-Step Refactoring
1. **Add import**:
```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
```
### For API/tRPC Handlers
1. **Add imports**:
```typescript
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
```
2. **Replace membership query**:
```typescript
const permissionCheckService = new PermissionCheckService();
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission(
ctx.user.id,
"team.listMembers"
);
```
```typescript
const permissionCheckService = new PermissionCheckService();
const teamsToQuery = await permissionCheckService.getTeamIdsWithPermission({
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
## 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**:
- Replaced 17 lines of membership query with 2 lines of PBAC
- Removed unnecessary imports and fallback logic
- Used direct permission string `"team.listMembers"`
**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
### For All Refactoring
- Run `yarn type-check:ci --force`
- Ensure existing privacy checks remain intact
- 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",
Workflow = "workflow",
Webhook = "webhook",
Availability = "availability",
OutOfOffice = "ooo",
}
export enum CrudAction {
@@ -114,6 +116,7 @@ export const isValidPermissionString = (val: unknown): val is PermissionString =
* @returns A new object without the _resource property
*/
export const filterResourceConfig = (config: ResourceConfig): Omit<ResourceConfig, "_resource"> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _resource, ...rest } = config;
return rest;
};
@@ -556,4 +559,76 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
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 { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { RoleManagementError, RoleManagementErrorCode } from "../domain/errors/role-management.error";
@@ -10,7 +12,13 @@ import { RoleService } from "./role.service";
interface IRoleManager {
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(
userId: number,
organizationId: number,
@@ -29,7 +37,17 @@ class PBACRoleManager implements IRoleManager {
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({
userId,
teamId: targetId,
@@ -95,7 +113,69 @@ class PBACRoleManager implements IRoleManager {
class LegacyRoleManager implements IRoleManager {
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;
if (scope === "team") {
const team = await prisma.membership.findFirst({
@@ -118,12 +198,25 @@ class LegacyRoleManager implements IRoleManager {
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(
userId: number,
organizationId: number,
role: MembershipRole | string,
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_membershipId: number
): Promise<void> {
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 }[]> {
return [
{ 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 }[]> {
return [
{ id: MembershipRole.OWNER, name: "Owner" },
@@ -156,6 +253,8 @@ class LegacyRoleManager implements IRoleManager {
}
}
export { LegacyRoleManager };
export class RoleManagementFactory {
private static instance: RoleManagementFactory;
private featuresRepository: FeaturesRepository;
@@ -163,6 +262,8 @@ export class RoleManagementFactory {
private permissionCheckService: PermissionCheckService;
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.permissionCheckService = new PermissionCheckService();
}
@@ -51,7 +51,7 @@ export function ScheduleListItem({
<div className="space-x-2 rtl:space-x-reverse">
<span className="text-emphasis truncate font-medium">{schedule.name}</span>
{schedule.isDefault && (
<Badge variant="success" className="text-xs">
<Badge variant="gray" className="text-xs">
{t("default")}
</Badge>
)}
@@ -26,12 +26,12 @@ export default function Header({
<div>
<header
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 === 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">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center">
{backButton && (
<a href="javascript:history.back()">
<Icon name="arrow-left" className="mr-7" />
@@ -52,7 +52,7 @@ export default function Header({
)}
</div>
</div>
<div className={classNames("flex-shrink-0 sm:ms-auto", ctaClassName)}>{CTA}</div>
<div className={classNames("flex-shrink-0", ctaClassName)}>{CTA}</div>
</div>
</header>
<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 { WEBAPP_URL } from "@calcom/lib/constants";
import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember";
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger";
@@ -11,6 +12,7 @@ import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { randomBytes } from "crypto";
const log = logger.getSubLogger({ prefix: ["TeamService"] });
@@ -47,6 +49,56 @@ export type RemoveMemberResult = {
};
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.
* External, critical services like billing are handled first to prevent data inconsistencies.
+6
View File
@@ -1,5 +1,11 @@
## 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
### Minor Changes
@@ -77,12 +77,16 @@ export const CreateEventTypePlatformWrapper = ({
const team = teams?.find((t) => t.id === teamId);
const { t } = useLocale();
const permissions = {
canCreateEventType: team?.role === "ADMIN" || team?.role === "OWNER",
};
return teamId && team ? (
<AtomsWrapper customClassName={customClassNames?.atomsWrapper}>
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={team.role === "ADMIN" || team.role === "OWNER"}
permissions={permissions}
urlPrefix=""
isPending={createTeamEventTypeQuery.isPending}
form={form}
@@ -1,6 +1,7 @@
import type { EventAdvancedBaseProps } 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 { localeOptions } from "@calcom/lib/i18n";
const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
const connectedCalendarsQuery = trpc.viewer.calendars.connectedCalendars.useQuery();
@@ -17,6 +18,7 @@ const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
}}
showBookerLayoutSelector={true}
verifiedEmails={verifiedEmails}
localeOptions={localeOptions}
/>
);
};
@@ -282,6 +282,7 @@ const EventTypeWeb = ({
user={user}
isUserLoading={isLoggedInUserPending}
showToast={showToast}
orgId={orgBranding?.id ?? null}
/>
),
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.9.0",
"version": "1.10.0",
"scripts": {
"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",
@@ -5,9 +5,9 @@ export class Disabled_2024_06_14 {
@IsOptional()
@IsBoolean()
@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,
default: false,
})
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 { disallowUndefinedDeleteUpdateManyExtension } from "./extensions/disallow-undefined-delete-update-many";
import { eventTypeTimestampsExtension } from "./extensions/event-type-timestamps";
import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users";
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
import { usageTrackingExtention } from "./extensions/usage-tracking";
@@ -43,7 +42,6 @@ export const customPrisma = (options?: Prisma.PrismaClientOptions) =>
.$extends(excludeLockedUsersExtension())
.$extends(excludePendingPaymentsExtension())
.$extends(bookingIdempotencyKeyExtension())
.$extends(eventTypeTimestampsExtension())
.$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient;
// If any changed on middleware server restart is required
@@ -60,7 +58,6 @@ export const prisma: PrismaClient = baseClient
.$extends(excludeLockedUsersExtension())
.$extends(excludePendingPaymentsExtension())
.$extends(bookingIdempotencyKeyExtension())
.$extends(eventTypeTimestampsExtension())
.$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient;
// 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-prisma-types": "^3.2.4"
},
"devDependencies": {
"npm-run-all": "^4.1.5"
},
"main": "index.ts",
"types": "index.d.ts",
"files": [
+1 -1
View File
@@ -244,7 +244,7 @@ model EventType {
bookingRequiresAuthentication Boolean @default(false)
createdAt DateTime?
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@unique([userId, slug])
@@ -1,7 +1,9 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -59,9 +61,37 @@ export const getUserEventGroups = async ({ ctx, input }: GetByViewerOptions) =>
const profileProcessor = new ProfilePermissionProcessor();
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 {
eventTypeGroups: filteredEventTypeGroups,
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
const [requestedUser, membership, teams] = await prisma.$transaction([
const [requestedUser, membership, teams] = await Promise.all([
prisma.user.findUnique({
where: { id: input.userId },
select: {
@@ -1,8 +1,6 @@
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 { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -30,9 +28,15 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
// Create role manager for this organization/team
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
// Check permission to change roles
// Check permission to change roles (includes legacy validation for LegacyRoleManager)
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) {
throw new TRPCError({
code: "UNAUTHORIZED",
@@ -40,59 +44,17 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
});
}
// For traditional role checks, fall back to existing logic
if (
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({
// Get the target membership for the assignRole method
const targetMembership = await prisma.membership.findUnique({
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) {
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
try {
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 { WEBAPP_URL } from "@calcom/lib/constants";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
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" });
const isOrganizationOrATeamInOrganization = !!(team.parentId || team.isOrganization);
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) };
const result = await TeamService.createInvite(teamId, { token: input.token });
return result;
};
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;
@@ -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 { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -14,7 +16,26 @@ type 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 });
};
@@ -492,7 +492,7 @@ export async function sendSignupToOrganizationEmail({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
connect: {
id: teamId,
@@ -717,7 +717,7 @@ export const sendExistingUserTeamInviteEmails = async ({
data: {
identifier: user.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
connect: {
id: teamId,
@@ -74,7 +74,14 @@ type AddonProps = {
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
onClick={onClickAddon && onClickAddon}
className={classNames(
@@ -208,6 +215,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
size={size}
className={classNames(
className,
type === "email" && "focus:border-subtle focus:ring-brand-default focus:ring-2",
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed"
)}
{...passThrough}
@@ -62,7 +62,7 @@ const VerticalTabItem = ({
target={props.isExternalLink ? "_blank" : "_self"}
className={classNames(
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",
(isChild || !props.icon) && "ml-7",
props.className
-1
View File
@@ -1,7 +1,6 @@
{
"$schema": "https://turborepo.org/schema.json",
"globalDependencies": ["yarn.lock"],
"ui": "tui",
"globalEnv": [
"ALLOWED_HOSTNAMES",
"ANALYZE",
+1
View File
@@ -3848,6 +3848,7 @@ __metadata:
"@prisma/client": ^6.7.0
"@prisma/extension-accelerate": ^1.3.0
"@prisma/generator-helper": ^6.7.0
npm-run-all: ^4.1.5
prisma: ^6.7.0
prisma-kysely: 2.2.0
ts-node: ^10.9.1