Compare commits

...
Author SHA1 Message Date
emrysal 6ff5c8ce5f chore: release v5.7.6
Post release / release (push) Canceled after 0s
2025-09-29 11:22:43 +00:00
Lauris Skraucis 3be7fb9dd8 fix: v2 create org user with time zone (#24133)
* fix: tzdata undefined import

* refactor: capitalize org user timezone

* test: org user creation with time zone
2025-09-29 11:12:18 +00:00
Benny Joo 72dd970b6e refactor: use permission check service in /organizations endpoints (#24099)
* getUserConnectedApps

* create teams

* use permission check in team deletion

* team read

* revert

* publish handler

* wip
2025-09-29 11:07:19 +00:00
sean-brydon f4d09ece43 chore: (PBAC) readonly eventTypes (#24131)
* Chore(PBAC): readonly eventTypes

* Set readonly value
2025-09-29 11:06:55 +00:00
sean-brydonandEunjae Lee 239e6085ba feat: pbac - private teams (#23998)
* Add permission + migration for listMembersPrivate + depends on lsitMember

* Add legacy list members PBAC private logic

* Add checks in team pages + handler

* add list memeber checks for private orgs

* Add permision visibility scope to loading permissions

* getMembers handler

* fix fallback permission

* Verify private teams work with private org

* Apply suggestion from @eunjae-lee

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* Remove and private scope function and add param to old

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-09-29 11:30:54 +01:00
sean-brydon b17eab3aeb chore: Use enums instead of hardcoded strings (#24132)
* Use enums instead of hardcoded strings

* use membership role
2025-09-29 10:57:36 +01:00
752f7fcde0 refactor: replace checkAdminOrOwner with team.create permission in teams page (#24116)
* refactor: replace checkAdminOrOwner with team.create permission in teams page

- Replace role-based access control with PBAC using team.create permission
- Follow PBAC refactoring guide pattern for server-side permission checking
- Use permission-specific variable naming (canCreateTeam)
- Maintain existing TeamsListing component interface

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

* refactor: implement permissions object pattern for TeamsListing

- Replace isOrgAdmin prop with permissions object containing canCreateTeam
- Follow PBAC refactoring guide pattern for UI components
- Update TeamsListing component to use permissions.canCreateTeam
- Maintain existing functionality while improving code structure
- All type checking and linting verification passed

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

* use different method

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-29 14:11:42 +05:30
f6b061f787 fix: update handler to not delete banner (#24106)
* Fix update handler to not delete banner

* code cleanup

* add banner-upload.e2e

* fix: add slug fallback in banner-upload e2e test

- Use org.slug as fallback when requestedSlug is undefined
- Addresses GitHub comment about guarding against undefined slug
- Prevents test failures when requestedSlug is not set
- Fix ESLint warnings: remove unnecessary escape characters and unused variable

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* test: add banner preservation test for profile updates

- Verify that updating organization profile without banner changes preserves existing banner
- Ensures update handler doesn't overwrite banner when undefined is passed
- Addresses user request to test banner preservation during profile saves

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* remove banner e2e wip

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 09:31:49 +01:00
Eunjae LeeandDevin AI cc8a1290ee docs: add comprehensive DataTable usage guide (#24107)
* docs: add comprehensive DataTable usage guide

- Document DataTableProvider, DataTableWrapper, and DataTable components
- Cover filter system with all filter types and operators
- Explain segment system (system vs user segments)
- Document pagination modes (infinite vs standard)
- Include real-world usage examples from UserListTable, BookingsListingView, MemberList
- Add TypeScript types reference and best practices

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

* docs: update DataTable guide to recommend standard pagination

- De-emphasize virtualized rendering and infinite mode
- Position standard pagination as the recommended approach
- Document known issues with infinite loading mode
- Reorder pagination section to prioritize standard mode
- Update best practices to recommend standard pagination

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

* docs: add missing DataTable guide topics

- Document getFacetedUniqueValues for faceted filters with real examples
- Add useSegments prop requirement for filter segment enablement
- Enhance server-side filtering documentation with comprehensive patterns
- Include examples from useFacetedUniqueValues and useInsightsRoutingParameters

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

* docs: add missing DataTable guide topics

- Document getFacetedUniqueValues for faceted filters with real examples
- Add useSegments prop requirement for filter segment enablement
- Enhance server-side filtering documentation with comprehensive patterns
- Include examples from useFacetedUniqueValues and useInsightsRoutingParameters
- Correct server-side vs client-side filtering rationale

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

* docs: enhance DataTable guide with troubleshooting and testing

- Remove duplicate Key Hooks section (lines 783-803)
- Add visual ASCII diagrams for architecture and component hierarchy
- Add comprehensive troubleshooting section with 7 common issues and solutions
- Add testing patterns section with Vitest examples and best practices
- Update table of contents to include new sections
- Maintain existing high-quality documentation standards

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

* remove some sections

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 07:59:55 +00:00
Eunjae LeeandDevin AI b7cf893169 refactor: replace checkAdminOrOwner with PBAC availability.read permission (#24115)
- Replace role-based check with PermissionCheckService
- Use availability.read permission with OWNER/ADMIN fallback roles
- Maintain existing privacy logic for organizations
- Follow established PBAC pattern from bookings implementation

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 07:59:23 +00:00
Lauris Skraucis 1458cacdbb fix: v2 api event type splitName booking field (#24111)
* fix: v2 api event type splitName booking field

* fix: test
2025-09-29 09:19:24 +02:00
42 changed files with 1954 additions and 153 deletions
View File
@@ -38,6 +38,7 @@ import type {
GuestsDefaultFieldOutput_2024_06_14,
NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14,
SplitNameDefaultFieldOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
@@ -1447,23 +1448,23 @@ describe("Event types Endpoints", () => {
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(falseTestUser.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(orgUser.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -2187,6 +2188,47 @@ describe("Event types Endpoints", () => {
});
});
describe("split name booking field", () => {
it("should create an event type with split name booking field", async () => {
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
isDefault: true,
type: "splitName",
slug: "splitName",
firstNameLabel: "First name",
firstNamePlaceholder: "",
lastNameLabel: "last name",
lastNamePlaceholder: "",
lastNameRequired: false,
disableOnPrefill: false,
};
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 9",
slug: "coding-class-9",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookingFields: [splitNameBookingField],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
(field) => field.type === "splitName"
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
await eventTypesRepositoryFixture.delete(createdEventType.id);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -2194,12 +2236,12 @@ describe("Event types Endpoints", () => {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -2379,7 +2421,7 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false);
expect(updatedEventType.hidden).toBe(false);
});
});
@@ -2389,12 +2431,12 @@ describe("Event types Endpoints", () => {
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
email: `organizations-users-new-member-${randomString()}@api.com`,
bio,
metadata,
timeZone: "Europe/Rome",
};
const emailSpy = jest
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
expect(userData.email).toBe(newOrgUser.email);
expect(userData.bio).toBe(newOrgUser.bio);
expect(userData.metadata).toEqual(newOrgUser.metadata);
expect(userData.timeZone).toBe(newOrgUser.timeZone);
expect(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email,
orgName: org.name,
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer";
import {
@@ -112,6 +113,7 @@ export class CreateUserInput {
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
@IsOptional()
@IsString()
@CapitalizeTimeZone()
@Validate(TimeZoneValidator)
@Expose()
timeZone?: string;
@@ -1,6 +1,6 @@
import type { ValidatorConstraintInterface } from "class-validator";
import { ValidatorConstraint } from "class-validator";
import tzdata from "tzdata";
import * as tzdata from "tzdata";
@ValidatorConstraint({ name: "timezoneValidator", async: false })
export class TimeZoneValidator implements ValidatorConstraintInterface {
@@ -5,10 +5,11 @@ import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { MembershipRole } from "@calcom/prisma/enums";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -72,8 +73,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
orgId: organizationId,
})
: false;
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgPrivate;
const permissionService = new PermissionCheckService();
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId: session.user.id,
permission: "availability.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
const canViewTeamAvailability = teamIdsWithPermission.length > 0 || !isOrgPrivate;
return (
<ShellMainAppDir
@@ -2,11 +2,12 @@ import type { SearchParams } from "app/_types";
import type { Session } from "next-auth";
import { unstable_cache } from "next/cache";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/teamService";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -49,21 +50,29 @@ export const ServerTeamsListing = async ({
const teams = await getCachedTeams(userId);
const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
const permissionCheckService = new PermissionCheckService();
const canCreateTeam = orgId
? await permissionCheckService.checkPermission({
userId: session.user.id,
teamId: orgId,
permission: "team.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
})
: false;
return {
Main: (
<TeamsListing
teams={teams}
orgId={orgId ?? null}
isOrgAdmin={isOrgAdminOrOwner}
permissions={{
canCreateTeam: canCreateTeam,
}}
teamNameFromInvite={teamNameFromInvite ?? null}
errorMsgFromInvite={errorMsgFromInvite}
/>
),
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null,
CTA: !orgId || canCreateTeam ? <TeamsCTA /> : null,
};
};
@@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps {
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
isPrivate?: boolean;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({
onChange,
disabled,
scope = Scope.Organization,
isPrivate = false,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);
@@ -56,9 +56,17 @@ interface RoleSheetProps {
onOpenChange: (open: boolean) => void;
teamId: number;
scope?: Scope;
isPrivate?: boolean; // Add isPrivate prop to control permission visibility
}
export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) {
export function RoleSheet({
role,
open,
onOpenChange,
teamId,
scope = Scope.Organization,
isPrivate = false
}: RoleSheetProps) {
const { t } = useLocale();
const router = useRouter();
const isEditing = Boolean(role);
@@ -108,7 +116,8 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
const { isAdvancedMode, permissions, color } = form.watch();
const { filteredResources, scopedRegistry } = useMemo(() => {
const scopedRegistry = getPermissionsForScope(scope);
// Use privacy-aware filtering if we have privacy information
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
t(
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
@@ -118,7 +127,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
.includes(searchQuery.toLowerCase())
);
return { filteredResources, scopedRegistry };
}, [searchQuery, t, scope]);
}, [searchQuery, t, scope, isPrivate]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => {
@@ -221,6 +230,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
isPrivate={isPrivate}
/>
))}{" "}
</div>
@@ -245,6 +255,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
isPrivate={isPrivate}
/>
))}
</div>{" "}
@@ -48,6 +48,7 @@ interface RolesListProps {
initialSheetOpen?: boolean;
teamId: number;
scope?: Scope;
isPrivate?: boolean;
}
export function RolesList({
@@ -57,6 +58,7 @@ export function RolesList({
initialSheetOpen,
teamId,
scope,
isPrivate,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
@@ -113,6 +115,7 @@ export function RolesList({
onOpenChange={handleSheetOpenChange}
teamId={teamId}
scope={scope}
isPrivate={isPrivate}
/>
</>
);
@@ -18,6 +18,7 @@ interface SimplePermissionItemProps {
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
isPrivate?: boolean;
}
export function SimplePermissionItem({
@@ -26,10 +27,11 @@ export function SimplePermissionItem({
onChange,
disabled,
scope = Scope.Organization,
isPrivate = false,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const registry = scopedRegistry || PERMISSION_REGISTRY;
const isAllResources = resource === "*";
@@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache(
{ revalidate: 3600 }
);
const getCachedTeamPrivacy = unstable_cache(
async (teamId: number) => {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { isPrivate: true },
});
return team?.isPrivate ?? false;
},
["team-privacy"],
{ revalidate: 3600 }
);
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("roles_and_permissions"),
@@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([
const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(session.user.org.id),
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role),
getCachedTeamPrivacy(session.user.org.id),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
@@ -108,6 +121,7 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
}}
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
isPrivate={isPrivate}
/>
</SettingsHeader>
);
@@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Impersonate,
],
fallbackRoles: {
@@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesCanListMembers,
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canListMembers: team.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
@@ -11,7 +12,6 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource:
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] }
);
const getCachedTeamPrivacy = (teamId: number) =>
unstable_cache(
async () => {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { isPrivate: true },
});
return team?.isPrivate ?? false;
},
[`team-privacy-${teamId}`],
{ revalidate: 3600, tags: [`team-privacy-${teamId}`] }
);
const getCachedTeam = (teamId: string, userId: number) =>
unstable_cache(
async () => {
@@ -111,9 +124,10 @@ const Page = async ({
roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([
const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(team.id)(),
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(),
getCachedTeamPrivacy(team.id)(),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
@@ -153,6 +167,7 @@ const Page = async ({
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
scope={Scope.Team}
isPrivate={isPrivate}
/>
</SettingsHeader>
);
@@ -70,6 +70,7 @@ const Page = async () => {
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
@@ -79,6 +80,9 @@ const Page = async () => {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
@@ -96,7 +100,9 @@ const Page = async () => {
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canListMembers: org.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
@@ -5,13 +5,13 @@ import {
getOrganizationSettings,
getVerifiedDomain,
} from "@calcom/features/ee/organizations/lib/orgSettings";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_CALCOM } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.7.5",
"version": "5.7.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -1,5 +1,5 @@
import type { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
export function checkAdminOrOwner(role: MembershipRole | null | undefined): role is "OWNER" | "ADMIN" {
return role === "OWNER" || role === "ADMIN";
return role === MembershipRole.OWNER || role === MembershipRole.ADMIN;
}
@@ -39,9 +39,9 @@ import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { Membership, Team, UserPermissionRole } from "@calcom/prisma/client";
import type { Membership, Team } from "@calcom/prisma/client";
import { CreationSource } from "@calcom/prisma/enums";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
@@ -249,7 +249,7 @@ const providers: Provider[] = [
// authentication success- but does it meet the minimum password requirements?
const validateRole = (role: UserPermissionRole) => {
// User's role is not "ADMIN"
if (role !== "ADMIN") return role;
if (role !== UserPermissionRole.ADMIN) return role;
// User's identity provider is not "CAL"
if (user.identityProvider !== IdentityProvider.CAL) return role;
@@ -2,6 +2,7 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
import logger from "@calcom/lib/logger";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { Payment } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { handleNoShowFee } from "./handleNoShowFee";
import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
@@ -32,7 +33,10 @@ export const processNoShowFeeOnCancellation = async ({
teamId: booking.eventType.teamId,
});
if (membership && (membership.role === "ADMIN" || membership.role === "OWNER")) {
if (
membership &&
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
) {
log.info(
`Booking ${booking.uid} was cancelled by team admin/owner (${cancelledByUserId}), skipping no-show fee`
);
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,7 @@ import prisma from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
import { Resource, CustomAction } from "../../../pbac/domain/types/permission-registry";
@@ -125,7 +125,8 @@ export function checkUserIdentifier(creds: Partial<Credentials>) {
export function checkGlobalPermission(session: Session | null) {
if (
(session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") ||
(session?.user.role !== UserPermissionRole.ADMIN &&
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") ||
!session?.user
) {
throw new Error("You do not have permission to do this.");
@@ -214,7 +215,11 @@ async function getImpersonatedUser({
// If you are an admin we dont need to follow this flow -> We can just follow the usual flow
// If orgId and teamId are the same we can follow the same flow
if (session?.user.org?.id && session.user.org.id !== teamId && session?.user.role !== "ADMIN") {
if (
session?.user.org?.id &&
session.user.org.id !== teamId &&
session?.user.role !== UserPermissionRole.ADMIN
) {
TeamWhereClause = {
disableImpersonation: false,
accepted: true,
@@ -300,7 +305,7 @@ async function isReturningToSelf({ session, creds }: { session: Session | null;
const inOrg =
returningUser.organizationId || // Keep for backwards compatibility
returningUser.profiles.some((profile) => profile.organizationId !== undefined); // New way of seeing if the user has a profile in orgs.
if (returningUser.role !== "ADMIN" && !inOrg) return;
if (returningUser.role !== UserPermissionRole.ADMIN && !inOrg) return;
const hasTeams = returningUser.teams.length >= 1;
@@ -353,7 +358,7 @@ const ImpersonationProvider = CredentialsProvider({
checkGlobalPermission(session);
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });
if (session?.user.role === "ADMIN") {
if (session?.user.role === UserPermissionRole.ADMIN) {
if (impersonatedUser.disableImpersonation) {
throw new Error("This user has disabled Impersonation.");
}
@@ -409,7 +414,10 @@ const ImpersonationProvider = CredentialsProvider({
// Legacy role check as additional safeguard (PBAC should handle this but keeping for backwards compatibility)
// We find team by ID so we know there is only one team in the array
if (sessionUserFromDb?.teams[0].role === "ADMIN" && impersonatedUser.teams[0].role === "OWNER") {
if (
sessionUserFromDb?.teams[0].role === MembershipRole.ADMIN &&
impersonatedUser.teams[0].role === MembershipRole.OWNER
) {
throw new Error("You do not have permission to do this.");
}
@@ -10,7 +10,7 @@ import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import type { BillingPeriod } from "@calcom/prisma/enums";
import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -294,7 +294,7 @@ export class OrganizationPaymentService {
const { orgOwnerEmail, pricePerSeat, slug, billingPeriod, seats } = organizationOnboarding;
if (this.user.role === "ADMIN") {
if (this.user.role === UserPermissionRole.ADMIN) {
log.debug("Admin flow, skipping checkout", safeStringify({ organizationOnboarding }));
return {
organizationOnboarding,
@@ -1,8 +1,10 @@
import { UserPermissionRole } from "@calcom/kysely/types";
import { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -50,7 +52,7 @@ export class OrganizationPermissionService {
}
hasPermissionToModifyDefaultPayment(): boolean {
return this.user.role === "ADMIN";
return this.user.role === UserPermissionRole.ADMIN;
}
hasModifiedDefaultPayment(data: SeatsPrice & { billingPeriod?: string }): boolean {
@@ -81,7 +83,7 @@ export class OrganizationPermissionService {
in: teamIds,
},
role: {
in: ["OWNER", "ADMIN"],
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
},
});
@@ -19,7 +19,9 @@ import TeamList from "./TeamList";
type TeamsListingProps = {
orgId: number | null;
isOrgAdmin: boolean;
permissions: {
canCreateTeam: boolean;
};
teams: RouterOutputs["viewer"]["teams"]["list"];
teamNameFromInvite: string | null;
errorMsgFromInvite: string | null;
@@ -27,7 +29,7 @@ type TeamsListingProps = {
export function TeamsListing({
orgId,
isOrgAdmin,
permissions,
teams: data,
teamNameFromInvite,
errorMsgFromInvite,
@@ -51,7 +53,7 @@ export function TeamsListing({
}
);
const isCreateTeamButtonDisabled = !!(orgId && !isOrgAdmin);
const isCreateTeamButtonDisabled = !!(orgId && !permissions.canCreateTeam);
const features = [
{
@@ -128,7 +130,7 @@ export function TeamsListing({
features={features}
background="/tips/teams"
buttons={
!orgId || isOrgAdmin ? (
!orgId || permissions.canCreateTeam ? (
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
@@ -1,4 +1,5 @@
//import "server-only";
import type { LocationObject } from "@calcom/app-store/locations";
import { getLocationGroupedOptions } from "@calcom/app-store/server";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
@@ -10,7 +11,6 @@ import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import type { LocationObject } from "@calcom/app-store/locations";
import { getTranslation } from "@calcom/lib/server/i18n";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -2,6 +2,7 @@
import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
@@ -51,6 +52,15 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
if (isFilterSet && filters?.upIds && !isUpIdInFilter) {
shouldListUserEvents = true;
}
// Get teams where user has eventType.read permission for PBAC readonly check
const permissionCheckService = new PermissionCheckService();
const teamsWithEventTypeReadPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "eventType.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
const eventTypeRepo = new EventTypeRepository(prisma);
const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes(
@@ -281,13 +291,7 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
},
metadata: {
membershipCount: team.members.length,
readOnly:
membership.role ===
(team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
readOnly: !teamsWithEventTypeReadPermission.includes(team.id),
},
eventTypes: eventTypes
.filter(filterByTeamIds)
@@ -27,6 +27,7 @@ export enum CustomAction {
Remove = "remove",
ChangeMemberRole = "changeMemberRole",
ListMembers = "listMembers",
ListMembersPrivate = "listMembersPrivate",
ManageBilling = "manageBilling",
ReadTeamBookings = "readTeamBookings",
ReadOrgBookings = "readOrgBookings",
@@ -46,6 +47,9 @@ export interface PermissionDetails {
descriptionI18nKey: string;
scope?: Scope[]; // Optional for backward compatibility
dependsOn?: PermissionString[]; // Dependencies that must be enabled when this permission is enabled
visibleWhen?: {
teamPrivacy?: "private" | "public" | "both"; // Control visibility based on team privacy setting
};
}
export type ResourceConfig = {
@@ -122,12 +126,14 @@ export const filterResourceConfig = (config: ResourceConfig): Omit<ResourceConfi
};
/**
* Filter resources and actions based on scope
* Filter resources and actions based on scope and optionally team privacy settings
* @param scope The scope to filter by (Team or Organization)
* @param isPrivate Whether the team/organization is private (optional)
* @returns Filtered permission registry
*/
export const getPermissionsForScope = (scope: Scope): PermissionRegistry => {
export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): PermissionRegistry => {
const filteredRegistry: Partial<PermissionRegistry> = {};
const teamPrivacy = isPrivate !== undefined ? (isPrivate ? "private" : "public") : undefined;
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
const filteredConfig: ResourceConfig = { _resource: config._resource };
@@ -136,9 +142,17 @@ export const getPermissionsForScope = (scope: Scope): PermissionRegistry => {
if (action === "_resource") return;
const permissionDetails = details as PermissionDetails;
// If no scope is defined, include in both Team and Organization (backward compatibility)
// If scope is defined, only include if it matches the requested scope
if (!permissionDetails.scope || permissionDetails.scope.includes(scope)) {
// Check scope
const scopeMatches = !permissionDetails.scope || permissionDetails.scope.includes(scope);
// Check privacy visibility (only if isPrivate is provided)
const privacyMatches = teamPrivacy === undefined ||
!permissionDetails.visibleWhen?.teamPrivacy ||
permissionDetails.visibleWhen.teamPrivacy === "both" ||
permissionDetails.visibleWhen.teamPrivacy === teamPrivacy;
if (scopeMatches && privacyMatches) {
filteredConfig[action as CrudAction | CustomAction] = permissionDetails;
}
});
@@ -282,6 +296,19 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
category: "team",
i18nKey: "pbac_action_list_members",
descriptionI18nKey: "pbac_desc_list_team_members",
visibleWhen: {
teamPrivacy: "public", // Only show for public teams
},
},
[CustomAction.ListMembersPrivate]: {
description: "List private team members",
category: "team",
i18nKey: "pbac_action_list_members", // Use same UI label as listMembers for consistency
descriptionI18nKey: "pbac_desc_list_team_members", // Use same description as listMembers
dependsOn: ["team.read"],
visibleWhen: {
teamPrivacy: "private", // Only show for private teams
},
},
[CustomAction.ChangeMemberRole]: {
description: "Change role of team members",
@@ -330,6 +357,20 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_list_organization_members",
scope: [Scope.Organization],
dependsOn: ["organization.read"],
visibleWhen: {
teamPrivacy: "public", // Only show for public orgs
},
},
[CustomAction.ListMembersPrivate]: {
description: "List private organization members",
category: "org",
i18nKey: "pbac_action_list_members", // Same UI label as listMembers for consistency
descriptionI18nKey: "pbac_desc_list_organization_members", // Same description as listMembers
scope: [Scope.Organization],
dependsOn: ["organization.read"],
visibleWhen: {
teamPrivacy: "private", // Only show for private orgs
},
},
[CustomAction.Invite]: {
description: "Invite organization members",
+2 -2
View File
@@ -3,7 +3,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
import type { UserPermissionRole } from "@calcom/prisma/enums";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
type WebhookGroup = {
@@ -162,7 +162,7 @@ export class WebhookRepository {
}
// Add platform webhooks for admins
if (userRole === "ADMIN") {
if (userRole === UserPermissionRole.ADMIN) {
const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true },
});
@@ -7,6 +7,7 @@ import { registerDecorator, validate, ValidatorConstraint } from "class-validato
const inputBookingFieldTypes = [
"name",
"splitName",
"email",
"phone",
"address",
@@ -0,0 +1,16 @@
-- This permission allows admin users to view members of private teams and organizations
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
-- Team listMembersPrivate permission
('team', 'listMembersPrivate'),
-- Organization listMembersPrivate permission
('organization', 'listMembersPrivate')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
-- Note: Owner role already has wildcard permissions (*.*) so it inherits all listMembersPrivate permissions automatically
@@ -1,6 +1,7 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
@@ -18,9 +19,21 @@ type AddBulkToEventTypeHandler = {
export async function addMembersToEventTypesHandler({ ctx, input }: AddBulkToEventTypeHandler) {
if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(ctx.user?.id, ctx.user.organizationId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
// Check if user has permission to manage event types in the organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: ctx.user.organizationId,
permission: "eventType.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to manage event types in this organization",
});
}
const { eventTypeIds, teamIds, userIds } = input;
@@ -1,5 +1,6 @@
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -41,24 +42,20 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) =>
throw new NotAuthorizedError();
}
// Validate user membership role
const userMembershipRole = await prisma.membership.findFirst({
where: {
userId: organizationOwner.id,
teamId: orgId,
role: {
in: ["OWNER", "ADMIN"],
},
// @TODO: not sure if this already setup earlier
// accepted: true,
},
select: {
role: true,
},
// Validate user has permission to create teams in the organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: organizationOwner.id,
teamId: orgId,
permission: "team.create",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!userMembershipRole) {
log.error("User is not a member of the organization", safeStringify({ orgId, organizationOwner }));
if (!hasPermission) {
log.error(
"User is not authorized to create teams in the organization",
safeStringify({ orgId, organizationOwner })
);
throw new NotAuthorizedError();
}
@@ -1,12 +1,52 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TDeleteTeamInputSchema } from "./deleteTeam.schema";
type DeleteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TDeleteTeamInputSchema;
};
export const deleteTeamHandler = async ({ input }: DeleteOptions) => {
export const deleteTeamHandler = async ({ ctx, input }: DeleteOptions) => {
const team = await prisma.team.findUnique({
where: { id: input.teamId },
select: {
id: true,
parentId: true,
},
});
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
if (!team.parentId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can only delete teams within organizations" });
}
// Check if user has permission to delete teams in this organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: team.parentId,
permission: "team.delete",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete teams in this organization",
});
}
// delete all memberships
await prisma.membership.deleteMany({
where: {
@@ -1,4 +1,6 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "../../../types";
import type { TGetMembersInputSchema } from "./getMembers.schema";
@@ -16,9 +18,21 @@ export const getMembersHandler = async ({ input, ctx }: CreateOptions) => {
if (!ctx.user.organizationId) return [];
const isOrgPrivate = ctx.user.organization.isPrivate;
const isOrgAdmin = ctx.user.organization.isOrgAdmin;
if (isOrgPrivate && !isOrgAdmin) return [];
const permissionCheckService = new PermissionCheckService();
const hasPermissionToViewMembers = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: ctx.user.organizationId,
permission: ctx.user.organization.isPrivate
? "organization.listMembersPrivate"
: "organization.listMembers",
fallbackRoles: ctx.user.organization.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasPermissionToViewMembers) return [];
const teamQuery = await prisma.team.findUnique({
where: {
@@ -0,0 +1,118 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { getBannerUrl } from "./update.handler";
vi.mock("@calcom/lib/server/avatar", () => ({
uploadLogo: vi.fn(),
}));
vi.mock("@calcom/lib/server/resizeBase64Image", () => ({
resizeBase64Image: vi.fn((input) => input),
}));
const { uploadLogo } = await import("@calcom/lib/server/avatar");
const { resizeBase64Image } = await import("@calcom/lib/server/resizeBase64Image");
describe("getBannerUrl", () => {
const mockTeamId = 123;
beforeEach(() => {
vi.clearAllMocks();
});
it("should return undefined when banner is undefined (not provided)", async () => {
const result = await getBannerUrl(undefined, mockTeamId);
expect(result).toBeUndefined();
expect(uploadLogo).not.toHaveBeenCalled();
expect(resizeBase64Image).not.toHaveBeenCalled();
});
it("should return null when banner is explicitly set to null", async () => {
const result = await getBannerUrl(null, mockTeamId);
expect(result).toBe(null);
expect(uploadLogo).not.toHaveBeenCalled();
expect(resizeBase64Image).not.toHaveBeenCalled();
});
it("should upload and return URL for valid PNG base64 banner", async () => {
const mockBannerUrl = "https://example.com/banner.png";
const validBase64 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
vi.mocked(uploadLogo).mockResolvedValue(mockBannerUrl);
const result = await getBannerUrl(validBase64, mockTeamId);
expect(result).toBe(mockBannerUrl);
expect(resizeBase64Image).toHaveBeenCalledWith(validBase64, { maxSize: 1500 });
expect(uploadLogo).toHaveBeenCalledWith({
logo: validBase64, // resizeBase64Image mock returns input unchanged
teamId: mockTeamId,
isBanner: true,
});
});
it("should upload and return URL for valid JPEG base64 banner", async () => {
const mockBannerUrl = "https://example.com/banner.jpg";
const validBase64 =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==";
vi.mocked(uploadLogo).mockResolvedValue(mockBannerUrl);
const result = await getBannerUrl(validBase64, mockTeamId);
expect(result).toBe(mockBannerUrl);
expect(resizeBase64Image).toHaveBeenCalledWith(validBase64, { maxSize: 1500 });
expect(uploadLogo).toHaveBeenCalledWith({
logo: validBase64,
teamId: mockTeamId,
isBanner: true,
});
});
it("should upload and return URL for valid JPG base64 banner", async () => {
const mockBannerUrl = "https://example.com/banner.jpg";
const validBase64 =
"data:image/jpg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==";
vi.mocked(uploadLogo).mockResolvedValue(mockBannerUrl);
const result = await getBannerUrl(validBase64, mockTeamId);
expect(result).toBe(mockBannerUrl);
expect(resizeBase64Image).toHaveBeenCalledWith(validBase64, { maxSize: 1500 });
expect(uploadLogo).toHaveBeenCalledWith({
logo: validBase64,
teamId: mockTeamId,
isBanner: true,
});
});
it("should return undefined for invalid banner string", async () => {
const result = await getBannerUrl("invalid-banner-string", mockTeamId);
expect(result).toBeUndefined();
expect(uploadLogo).not.toHaveBeenCalled();
expect(resizeBase64Image).not.toHaveBeenCalled();
});
it("should return undefined for empty banner string", async () => {
const result = await getBannerUrl("", mockTeamId);
expect(result).toBeUndefined();
expect(uploadLogo).not.toHaveBeenCalled();
expect(resizeBase64Image).not.toHaveBeenCalled();
});
it("should return undefined for non-image base64 string", async () => {
const invalidBase64 = "data:text/plain;base64,SGVsbG8gV29ybGQ=";
const result = await getBannerUrl(invalidBase64, mockTeamId);
expect(result).toBeUndefined();
expect(uploadLogo).not.toHaveBeenCalled();
expect(resizeBase64Image).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import { listMembersHandler } from "./listMembers.handler";
vi.mock("@calcom/prisma", () => ({
prisma,
default: prisma, // Add default export for db
}));
// Mock FeaturesRepository
@@ -22,9 +23,18 @@ vi.mock("@calcom/features/flags/features.repository", () => ({
vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({
getSpecificPermissions: vi.fn().mockResolvedValue({
listMembers: true,
listMembersPrivate: true,
}),
}));
// Mock PermissionCheckService
const mockCheckPermission = vi.fn().mockResolvedValue(true);
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
PermissionCheckService: vi.fn().mockImplementation(() => ({
checkPermission: mockCheckPermission,
})),
}));
// Mock UserRepository
vi.mock("@calcom/lib/server/repository/user", () => ({
UserRepository: vi.fn().mockImplementation(() => ({
@@ -65,6 +75,8 @@ describe("listMembersHandler", () => {
prisma.membership.findMany.mockResolvedValue([]);
prisma.membership.count.mockResolvedValue(0);
prisma.membership.findFirst.mockResolvedValue({ role: "ADMIN" });
// Mock team.findUnique to return only isPrivate field since that's what the handler selects
prisma.team.findUnique.mockResolvedValue({ isPrivate: false } as any);
});
it("should filter by customRoleId when PBAC is enabled", async () => {
@@ -234,4 +246,72 @@ describe("listMembersHandler", () => {
})
);
});
it("should check listMembersPrivate permission for private organizations", async () => {
// Clear previous calls
mockCheckPermission.mockClear();
mockCheckPermission.mockResolvedValue(true);
// Mock private organization
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma.team.findUnique.mockResolvedValue({ isPrivate: true } as any);
const privateOrgUser = {
...mockUser,
organization: {
...mockUser.organization,
isPrivate: true,
},
};
await listMembersHandler({
ctx: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
user: privateOrgUser as any,
},
input: {
limit: 25,
offset: 0,
filters: [],
},
});
// Verify that checkPermission was called with listMembersPrivate
expect(mockCheckPermission).toHaveBeenCalledWith(
expect.objectContaining({
permission: "organization.listMembersPrivate",
fallbackRoles: ["ADMIN", "OWNER"],
})
);
});
it("should check listMembers permission for public organizations", async () => {
// Clear previous calls
mockCheckPermission.mockClear();
mockCheckPermission.mockResolvedValue(true);
// Mock public organization (default)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma.team.findUnique.mockResolvedValue({ isPrivate: false } as any);
await listMembersHandler({
ctx: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
user: mockUser as any,
},
input: {
limit: 25,
offset: 0,
filters: [],
},
});
// Verify that checkPermission was called with listMembers
expect(mockCheckPermission).toHaveBeenCalledWith(
expect.objectContaining({
permission: "organization.listMembers",
fallbackRoles: ["MEMBER", "ADMIN", "OWNER"],
})
);
});
});
@@ -3,6 +3,7 @@ import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -87,23 +88,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
}
// Check PBAC permissions for listing organization members
const permissions = await getSpecificPermissions({
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: organizationId,
resource: Resource.Organization,
userRole: membership.role,
actions: [CustomAction.ListMembers],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: ctx.user.organization.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
permission: ctx.user.organization.isPrivate
? "organization.listMembersPrivate"
: "organization.listMembers",
fallbackRoles: ctx.user.organization.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!permissions[CustomAction.ListMembers]) {
if (!hasPermission) {
return {
canUserGetMembers: false,
rows: [],
@@ -1,8 +1,9 @@
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -20,7 +21,21 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
if (!orgId)
throw new TRPCError({ code: "UNAUTHORIZED", message: "You do not have an organization to upgrade" });
if (!(await isOrganisationAdmin(ctx.user.id, orgId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Check if user has permission to publish the organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: orgId,
permission: "organization.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to publish this organization",
});
}
const prevTeam = await prisma.team.findUnique({
where: {
@@ -66,7 +81,6 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
const { requestedSlug, ...newMetadata } = metadata.data;
let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>;
try {
updatedTeam = await prisma.team.update({
where: { id: orgId },
@@ -15,6 +15,38 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TUpdateInputSchema } from "./update.schema";
export const getBannerUrl = async (
banner: string | null | undefined,
teamId: number
): Promise<string | null | undefined> => {
if (banner === undefined) {
// Banner not provided, don't update
return undefined;
}
if (banner === null) {
// Explicitly set to null, remove banner
return null;
}
if (
banner.startsWith("data:image/png;base64,") ||
banner.startsWith("data:image/jpeg;base64,") ||
banner.startsWith("data:image/jpg;base64,")
) {
// Valid base64 image, resize and upload
const resizedBanner = await resizeBase64Image(banner, { maxSize: 1500 });
return await uploadLogo({
logo: resizedBanner,
teamId,
isBanner: true,
});
}
// Invalid banner string, don't update
return undefined;
};
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
@@ -185,21 +217,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }),
};
if (
input.banner &&
(input.banner.startsWith("data:image/png;base64,") ||
input.banner.startsWith("data:image/jpeg;base64,") ||
input.banner.startsWith("data:image/jpg;base64,"))
) {
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
data.bannerUrl = await uploadLogo({
logo: banner,
teamId: currentOrgId,
isBanner: true,
});
} else {
data.bannerUrl = null;
}
data.bannerUrl = await getBannerUrl(input.banner, currentOrgId);
if (
input.logoUrl &&
@@ -1,3 +1,6 @@
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -16,14 +19,18 @@ type ListMembersOptions = {
export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
const { prisma } = ctx;
const { isOrgAdmin } = ctx.user.organization;
const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin;
const orgId = ctx.user.organizationId;
if (!hasPermsToView) {
return {
members: [],
nextCursor: undefined,
};
// Check PBAC permissions for the organization if it's private
if (orgId) {
const hasPermsToView = await checkCanAccessOrgMembers(ctx, orgId);
if (!hasPermsToView) {
return {
members: [],
nextCursor: undefined,
};
}
}
const limit = input.limit ?? 10;
@@ -129,4 +136,30 @@ export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
};
};
const checkCanAccessOrgMembers = async (ctx: ListMembersOptions["ctx"], orgId: number): Promise<boolean> => {
const { prisma } = ctx;
// Get organization info to verify it's private
const org = await prisma.team.findUnique({
where: { id: orgId },
select: { isPrivate: true },
});
if (!org) return false;
// Check PBAC permissions for listing members
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: orgId,
permission: org.isPrivate ? "organization.listMembersPrivate" : "organization.listMembers",
fallbackRoles: org.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
return hasPermission;
};
export default legacyListMembers;
@@ -1,5 +1,10 @@
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import {
Resource,
CustomAction,
PermissionString,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { TeamRepository } from "@calcom/lib/server/repository/team";
@@ -157,37 +162,22 @@ const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], team
if (!team) return false;
// Get user's membership in the team
const membership = await prisma.membership.findFirst({
where: {
teamId,
userId: ctx.user.id,
accepted: true,
},
});
if (!membership) return false;
// Determine the resource type based on whether this is an org or team
const resource = isTargetingOrg ? Resource.Organization : Resource.Team;
const targetAction = team.isPrivate ? CustomAction.ListMembersPrivate : CustomAction.ListMembers;
const permissionString = `${resource}.${targetAction}` as PermissionString;
// Check PBAC permissions for listing members
const permissions = await getSpecificPermissions({
const permissionCheckService = new PermissionCheckService();
return permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: teamId,
resource: resource,
userRole: membership.role,
actions: [CustomAction.ListMembers],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: team.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
permission: permissionString,
fallbackRoles: team.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
return permissions[CustomAction.ListMembers];
};
export default listMembersHandler;