Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48a1bd5d8 | ||
|
|
1acd0a14ba | ||
|
|
6c9ac7636b | ||
|
|
d261a23dbc | ||
|
|
8b7947a172 | ||
|
|
89789b1e3b | ||
|
|
43dd24c480 | ||
|
|
ae6b1d402a | ||
|
|
3054ad33b2 | ||
|
|
a4dbb1a90b | ||
|
|
689150d78a | ||
|
|
fba096f23d | ||
|
|
7d5a3391f1 | ||
|
|
dcef99bf49 | ||
|
|
805fcc22a5 | ||
|
|
c5b7da8ffd | ||
|
|
97f1f7b130 | ||
|
|
0ac460fce3 | ||
|
|
82dfca8272 | ||
|
|
d1c5576d4f | ||
|
|
7090e3ed60 | ||
|
|
9926fd2e79 | ||
|
|
7793cb20d1 | ||
|
|
8ed0fb6a07 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Reduced bundle size by removing unnecessary types
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+135
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
@@ -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")}
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
|
||||
+38
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
team: {
|
||||
name: "Global",
|
||||
name: "Default",
|
||||
},
|
||||
};
|
||||
logger.debug(
|
||||
|
||||
@@ -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"> {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")}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+2
@@ -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';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."EventType" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
+45
@@ -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;
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turborepo.org/schema.json",
|
||||
"globalDependencies": ["yarn.lock"],
|
||||
"ui": "tui",
|
||||
"globalEnv": [
|
||||
"ALLOWED_HOSTNAMES",
|
||||
"ANALYZE",
|
||||
|
||||
Reference in New Issue
Block a user