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, GuestsDefaultFieldOutput_2024_06_14,
NameDefaultFieldInput_2024_06_14, NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14, NotesDefaultFieldInput_2024_06_14,
SplitNameDefaultFieldOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types"; } from "@calcom/platform-types";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client"; import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
@@ -1447,23 +1448,23 @@ describe("Event types Endpoints", () => {
try { try {
await eventTypesRepositoryFixture.delete(eventType.id); await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) { } catch (e) {
// Event type might have been deleted by the test console.log(e);
} }
try { try {
await userRepositoryFixture.delete(user.id); await userRepositoryFixture.delete(user.id);
} catch (e) { } catch (e) {
// User might have been deleted by the test console.log(e);
} }
try { try {
await userRepositoryFixture.delete(falseTestUser.id); await userRepositoryFixture.delete(falseTestUser.id);
} catch (e) { } catch (e) {
// User might have been deleted by the test console.log(e);
} }
try { try {
await userRepositoryFixture.delete(orgUser.id); await userRepositoryFixture.delete(orgUser.id);
} catch (e) { } catch (e) {
// User might have been deleted by the test console.log(e);
} }
await app.close(); 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 () => { afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id); await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id); await teamRepositoryFixture.delete(organization.id);
@@ -2194,12 +2236,12 @@ describe("Event types Endpoints", () => {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1); await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2); await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) { } catch (e) {
// Event type might have been deleted by the test console.log(e);
} }
try { try {
await userRepositoryFixture.delete(user.id); await userRepositoryFixture.delete(user.id);
} catch (e) { } catch (e) {
// User might have been deleted by the test console.log(e);
} }
await app.close(); await app.close();
}); });
@@ -2379,7 +2421,7 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body; const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data; const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false); expect(updatedEventType.hidden).toBe(false);
}); });
}); });
@@ -2389,12 +2431,12 @@ describe("Event types Endpoints", () => {
try { try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1); await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
} catch (e) { } catch (e) {
// Event type might have been deleted by the test console.log(e);
} }
try { try {
await userRepositoryFixture.delete(user.id); await userRepositoryFixture.delete(user.id);
} catch (e) { } catch (e) {
// User might have been deleted by the test console.log(e);
} }
await app.close(); await app.close();
}); });
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
email: `organizations-users-new-member-${randomString()}@api.com`, email: `organizations-users-new-member-${randomString()}@api.com`,
bio, bio,
metadata, metadata,
timeZone: "Europe/Rome",
}; };
const emailSpy = jest const emailSpy = jest
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
expect(userData.email).toBe(newOrgUser.email); expect(userData.email).toBe(newOrgUser.email);
expect(userData.bio).toBe(newOrgUser.bio); expect(userData.bio).toBe(newOrgUser.bio);
expect(userData.metadata).toEqual(newOrgUser.metadata); expect(userData.metadata).toEqual(newOrgUser.metadata);
expect(userData.timeZone).toBe(newOrgUser.timeZone);
expect(emailSpy).toHaveBeenCalledWith({ expect(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email, usernameOrEmail: newOrgUser.email,
orgName: org.name, orgName: org.name,
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer"; import { Expose, Transform } from "class-transformer";
import { import {
@@ -112,6 +113,7 @@ export class CreateUserInput {
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" }) @ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
@IsOptional() @IsOptional()
@IsString() @IsString()
@CapitalizeTimeZone()
@Validate(TimeZoneValidator) @Validate(TimeZoneValidator)
@Expose() @Expose()
timeZone?: string; timeZone?: string;
@@ -1,6 +1,6 @@
import type { ValidatorConstraintInterface } from "class-validator"; import type { ValidatorConstraintInterface } from "class-validator";
import { ValidatorConstraint } from "class-validator"; import { ValidatorConstraint } from "class-validator";
import tzdata from "tzdata"; import * as tzdata from "tzdata";
@ValidatorConstraint({ name: "timezoneValidator", async: false }) @ValidatorConstraint({ name: "timezoneValidator", async: false })
export class TimeZoneValidator implements ValidatorConstraintInterface { export class TimeZoneValidator implements ValidatorConstraintInterface {
@@ -5,10 +5,11 @@ import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable"; import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; 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 { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -72,8 +73,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
orgId: organizationId, orgId: organizationId,
}) })
: false; : 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 ( return (
<ShellMainAppDir <ShellMainAppDir
@@ -2,11 +2,12 @@ import type { SearchParams } from "app/_types";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing"; 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 { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/teamService"; import { TeamService } from "@calcom/lib/server/service/teamService";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -49,21 +50,29 @@ export const ServerTeamsListing = async ({
const teams = await getCachedTeams(userId); const teams = await getCachedTeams(userId);
const userProfile = session?.user?.profile; const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id; const orgId = userProfile?.organizationId ?? session?.user.org?.id;
const orgRole =
session?.user?.org?.role ?? const permissionCheckService = new PermissionCheckService();
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role; const canCreateTeam = orgId
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole); ? await permissionCheckService.checkPermission({
userId: session.user.id,
teamId: orgId,
permission: "team.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
})
: false;
return { return {
Main: ( Main: (
<TeamsListing <TeamsListing
teams={teams} teams={teams}
orgId={orgId ?? null} orgId={orgId ?? null}
isOrgAdmin={isOrgAdminOrOwner} permissions={{
canCreateTeam: canCreateTeam,
}}
teamNameFromInvite={teamNameFromInvite ?? null} teamNameFromInvite={teamNameFromInvite ?? null}
errorMsgFromInvite={errorMsgFromInvite} errorMsgFromInvite={errorMsgFromInvite}
/> />
), ),
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null, CTA: !orgId || canCreateTeam ? <TeamsCTA /> : null,
}; };
}; };
@@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps {
onChange: (permissions: string[]) => void; onChange: (permissions: string[]) => void;
disabled?: boolean; disabled?: boolean;
scope?: Scope; scope?: Scope;
isPrivate?: boolean;
} }
const INTERNAL_DATAACCESS_KEY = "_resource"; const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({
onChange, onChange,
disabled, disabled,
scope = Scope.Organization, scope = Scope.Organization,
isPrivate = false,
}: AdvancedPermissionGroupProps) { }: AdvancedPermissionGroupProps) {
const { t } = useLocale(); const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope); const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope); const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const resourceConfig = scopedRegistry[resource]; const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -56,9 +56,17 @@ interface RoleSheetProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
teamId: number; teamId: number;
scope?: Scope; 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 { t } = useLocale();
const router = useRouter(); const router = useRouter();
const isEditing = Boolean(role); 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 { isAdvancedMode, permissions, color } = form.watch();
const { filteredResources, scopedRegistry } = useMemo(() => { 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) => const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
t( t(
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]] 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()) .includes(searchQuery.toLowerCase())
); );
return { filteredResources, scopedRegistry }; return { filteredResources, scopedRegistry };
}, [searchQuery, t, scope]); }, [searchQuery, t, scope, isPrivate]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({ const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => { onSuccess: async () => {
@@ -221,6 +230,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)} onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole} disabled={isSystemRole}
scope={scope} scope={scope}
isPrivate={isPrivate}
/> />
))}{" "} ))}{" "}
</div> </div>
@@ -245,6 +255,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)} onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole} disabled={isSystemRole}
scope={scope} scope={scope}
isPrivate={isPrivate}
/> />
))} ))}
</div>{" "} </div>{" "}
@@ -48,6 +48,7 @@ interface RolesListProps {
initialSheetOpen?: boolean; initialSheetOpen?: boolean;
teamId: number; teamId: number;
scope?: Scope; scope?: Scope;
isPrivate?: boolean;
} }
export function RolesList({ export function RolesList({
@@ -57,6 +58,7 @@ export function RolesList({
initialSheetOpen, initialSheetOpen,
teamId, teamId,
scope, scope,
isPrivate,
}: RolesListProps) { }: RolesListProps) {
const { t } = useLocale(); const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates( const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
@@ -113,6 +115,7 @@ export function RolesList({
onOpenChange={handleSheetOpenChange} onOpenChange={handleSheetOpenChange}
teamId={teamId} teamId={teamId}
scope={scope} scope={scope}
isPrivate={isPrivate}
/> />
</> </>
); );
@@ -18,6 +18,7 @@ interface SimplePermissionItemProps {
onChange: (permissions: string[]) => void; onChange: (permissions: string[]) => void;
disabled?: boolean; disabled?: boolean;
scope?: Scope; scope?: Scope;
isPrivate?: boolean;
} }
export function SimplePermissionItem({ export function SimplePermissionItem({
@@ -26,10 +27,11 @@ export function SimplePermissionItem({
onChange, onChange,
disabled, disabled,
scope = Scope.Organization, scope = Scope.Organization,
isPrivate = false,
}: SimplePermissionItemProps) { }: SimplePermissionItemProps) {
const { t } = useLocale(); const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope); const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope); const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const registry = scopedRegistry || PERMISSION_REGISTRY; const registry = scopedRegistry || PERMISSION_REGISTRY;
const isAllResources = resource === "*"; const isAllResources = resource === "*";
@@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache(
{ revalidate: 3600 } { 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 () => export const generateMetadata = async () =>
await _generateMetadata( await _generateMetadata(
(t) => t("roles_and_permissions"), (t) => t("roles_and_permissions"),
@@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
roleSearchParamsCache.parse(searchParams); roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([ const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(session.user.org.id), getCachedTeamRoles(session.user.org.id),
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role), 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. // 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} initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen} initialSheetOpen={isSheetOpen}
isPrivate={isPrivate}
/> />
</SettingsHeader> </SettingsHeader>
); );
@@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
CustomAction.ChangeMemberRole, CustomAction.ChangeMemberRole,
CustomAction.Remove, CustomAction.Remove,
CustomAction.ListMembers, CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Impersonate, CustomAction.Impersonate,
], ],
fallbackRoles: { fallbackRoles: {
@@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
[CustomAction.Impersonate]: { [CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER], roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}, },
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesCanListMembers,
},
}, },
}); });
// Map specific permissions to member actions // Map specific permissions to member actions
const memberPermissions = { const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers], canListMembers: team.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite], canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole], canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove], canRemove: permissions[CustomAction.Remove],
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; 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 type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper"; 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 { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service"; import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource:
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] } { 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) => const getCachedTeam = (teamId: string, userId: number) =>
unstable_cache( unstable_cache(
async () => { async () => {
@@ -111,9 +124,10 @@ const Page = async ({
roleSearchParamsCache.parse(searchParams); roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([ const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(team.id)(), getCachedTeamRoles(team.id)(),
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(), 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. // NOTE: this approach of fetching permssions per resource does not account for fall back roles.
@@ -153,6 +167,7 @@ const Page = async ({
initialSelectedRole={selectedRole} initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen} initialSheetOpen={isSheetOpen}
scope={Scope.Team} scope={Scope.Team}
isPrivate={isPrivate}
/> />
</SettingsHeader> </SettingsHeader>
); );
@@ -70,6 +70,7 @@ const Page = async () => {
userRole: session.user.org.role, userRole: session.user.org.role,
actions: [ actions: [
CustomAction.ListMembers, CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite, CustomAction.Invite,
CustomAction.ChangeMemberRole, CustomAction.ChangeMemberRole,
CustomAction.Remove, CustomAction.Remove,
@@ -79,6 +80,9 @@ const Page = async () => {
[CustomAction.ListMembers]: { [CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers, roles: fallbackRolesThatCanSeeMembers,
}, },
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: { [CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER], roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}, },
@@ -96,7 +100,9 @@ const Page = async () => {
// Map specific permissions to member actions // Map specific permissions to member actions
const memberPermissions = { const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers], canListMembers: org.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite], canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole], canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove], canRemove: permissions[CustomAction.Remove],
@@ -5,13 +5,13 @@ import {
getOrganizationSettings, getOrganizationSettings,
getVerifiedDomain, getVerifiedDomain,
} from "@calcom/features/ee/organizations/lib/orgSettings"; } 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 { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_CALCOM } from "@calcom/lib/constants"; import { IS_CALCOM } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import slugify from "@calcom/lib/slugify"; import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown"; import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "5.7.5", "version": "5.7.6",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "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" { 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 { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify"; import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma"; 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 { 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 { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail"; import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
@@ -249,7 +249,7 @@ const providers: Provider[] = [
// authentication success- but does it meet the minimum password requirements? // authentication success- but does it meet the minimum password requirements?
const validateRole = (role: UserPermissionRole) => { const validateRole = (role: UserPermissionRole) => {
// User's role is not "ADMIN" // User's role is not "ADMIN"
if (role !== "ADMIN") return role; if (role !== UserPermissionRole.ADMIN) return role;
// User's identity provider is not "CAL" // User's identity provider is not "CAL"
if (user.identityProvider !== IdentityProvider.CAL) return role; 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 logger from "@calcom/lib/logger";
import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { Payment } from "@calcom/prisma/client"; import type { Payment } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { handleNoShowFee } from "./handleNoShowFee"; import { handleNoShowFee } from "./handleNoShowFee";
import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee"; import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
@@ -32,7 +33,10 @@ export const processNoShowFeeOnCancellation = async ({
teamId: booking.eventType.teamId, teamId: booking.eventType.teamId,
}); });
if (membership && (membership.role === "ADMIN" || membership.role === "OWNER")) { if (
membership &&
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
) {
log.info( log.info(
`Booking ${booking.uid} was cancelled by team admin/owner (${cancelledByUserId}), skipping no-show fee` `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 { User } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client";
import type { Membership } 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 type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
import { Resource, CustomAction } from "../../../pbac/domain/types/permission-registry"; 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) { export function checkGlobalPermission(session: Session | null) {
if ( 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 !session?.user
) { ) {
throw new Error("You do not have permission to do this."); 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 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 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 = { TeamWhereClause = {
disableImpersonation: false, disableImpersonation: false,
accepted: true, accepted: true,
@@ -300,7 +305,7 @@ async function isReturningToSelf({ session, creds }: { session: Session | null;
const inOrg = const inOrg =
returningUser.organizationId || // Keep for backwards compatibility 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. 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; const hasTeams = returningUser.teams.length >= 1;
@@ -353,7 +358,7 @@ const ImpersonationProvider = CredentialsProvider({
checkGlobalPermission(session); checkGlobalPermission(session);
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds }); const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });
if (session?.user.role === "ADMIN") { if (session?.user.role === UserPermissionRole.ADMIN) {
if (impersonatedUser.disableImpersonation) { if (impersonatedUser.disableImpersonation) {
throw new Error("This user has disabled Impersonation."); 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) // 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 // 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."); 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 { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import type { OrganizationOnboarding } from "@calcom/prisma/client"; 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 { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -294,7 +294,7 @@ export class OrganizationPaymentService {
const { orgOwnerEmail, pricePerSeat, slug, billingPeriod, seats } = organizationOnboarding; 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 })); log.debug("Admin flow, skipping checkout", safeStringify({ organizationOnboarding }));
return { return {
organizationOnboarding, 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 { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -50,7 +52,7 @@ export class OrganizationPermissionService {
} }
hasPermissionToModifyDefaultPayment(): boolean { hasPermissionToModifyDefaultPayment(): boolean {
return this.user.role === "ADMIN"; return this.user.role === UserPermissionRole.ADMIN;
} }
hasModifiedDefaultPayment(data: SeatsPrice & { billingPeriod?: string }): boolean { hasModifiedDefaultPayment(data: SeatsPrice & { billingPeriod?: string }): boolean {
@@ -81,7 +83,7 @@ export class OrganizationPermissionService {
in: teamIds, in: teamIds,
}, },
role: { role: {
in: ["OWNER", "ADMIN"], in: [MembershipRole.OWNER, MembershipRole.ADMIN],
}, },
}, },
}); });
@@ -19,7 +19,9 @@ import TeamList from "./TeamList";
type TeamsListingProps = { type TeamsListingProps = {
orgId: number | null; orgId: number | null;
isOrgAdmin: boolean; permissions: {
canCreateTeam: boolean;
};
teams: RouterOutputs["viewer"]["teams"]["list"]; teams: RouterOutputs["viewer"]["teams"]["list"];
teamNameFromInvite: string | null; teamNameFromInvite: string | null;
errorMsgFromInvite: string | null; errorMsgFromInvite: string | null;
@@ -27,7 +29,7 @@ type TeamsListingProps = {
export function TeamsListing({ export function TeamsListing({
orgId, orgId,
isOrgAdmin, permissions,
teams: data, teams: data,
teamNameFromInvite, teamNameFromInvite,
errorMsgFromInvite, errorMsgFromInvite,
@@ -51,7 +53,7 @@ export function TeamsListing({
} }
); );
const isCreateTeamButtonDisabled = !!(orgId && !isOrgAdmin); const isCreateTeamButtonDisabled = !!(orgId && !permissions.canCreateTeam);
const features = [ const features = [
{ {
@@ -128,7 +130,7 @@ export function TeamsListing({
features={features} features={features}
background="/tips/teams" background="/tips/teams"
buttons={ buttons={
!orgId || isOrgAdmin ? ( !orgId || permissions.canCreateTeam ? (
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2"> <div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup> <ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}> <Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
@@ -1,4 +1,5 @@
//import "server-only"; //import "server-only";
import type { LocationObject } from "@calcom/app-store/locations";
import { getLocationGroupedOptions } from "@calcom/app-store/server"; import { getLocationGroupedOptions } from "@calcom/app-store/server";
import { getEventTypeAppData } from "@calcom/app-store/utils"; import { getEventTypeAppData } from "@calcom/app-store/utils";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-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 { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor"; import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import type { LocationObject } from "@calcom/app-store/locations";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import { UserRepository } from "@calcom/lib/server/repository/user"; import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -2,6 +2,7 @@
import { orderBy } from "lodash"; import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; 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 { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; 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) { if (isFilterSet && filters?.upIds && !isUpIdInFilter) {
shouldListUserEvents = true; 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 eventTypeRepo = new EventTypeRepository(prisma);
const [profileMemberships, profileEventTypes] = await Promise.all([ const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes( MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes(
@@ -281,13 +291,7 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
}, },
metadata: { metadata: {
membershipCount: team.members.length, membershipCount: team.members.length,
readOnly: readOnly: !teamsWithEventTypeReadPermission.includes(team.id),
membership.role ===
(team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
}, },
eventTypes: eventTypes eventTypes: eventTypes
.filter(filterByTeamIds) .filter(filterByTeamIds)
@@ -27,6 +27,7 @@ export enum CustomAction {
Remove = "remove", Remove = "remove",
ChangeMemberRole = "changeMemberRole", ChangeMemberRole = "changeMemberRole",
ListMembers = "listMembers", ListMembers = "listMembers",
ListMembersPrivate = "listMembersPrivate",
ManageBilling = "manageBilling", ManageBilling = "manageBilling",
ReadTeamBookings = "readTeamBookings", ReadTeamBookings = "readTeamBookings",
ReadOrgBookings = "readOrgBookings", ReadOrgBookings = "readOrgBookings",
@@ -46,6 +47,9 @@ export interface PermissionDetails {
descriptionI18nKey: string; descriptionI18nKey: string;
scope?: Scope[]; // Optional for backward compatibility scope?: Scope[]; // Optional for backward compatibility
dependsOn?: PermissionString[]; // Dependencies that must be enabled when this permission is enabled 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 = { 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 scope The scope to filter by (Team or Organization)
* @param isPrivate Whether the team/organization is private (optional)
* @returns Filtered permission registry * @returns Filtered permission registry
*/ */
export const getPermissionsForScope = (scope: Scope): PermissionRegistry => { export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): PermissionRegistry => {
const filteredRegistry: Partial<PermissionRegistry> = {}; const filteredRegistry: Partial<PermissionRegistry> = {};
const teamPrivacy = isPrivate !== undefined ? (isPrivate ? "private" : "public") : undefined;
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => { Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
const filteredConfig: ResourceConfig = { _resource: config._resource }; const filteredConfig: ResourceConfig = { _resource: config._resource };
@@ -136,9 +142,17 @@ export const getPermissionsForScope = (scope: Scope): PermissionRegistry => {
if (action === "_resource") return; if (action === "_resource") return;
const permissionDetails = details as PermissionDetails; 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 // Check scope
if (!permissionDetails.scope || permissionDetails.scope.includes(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; filteredConfig[action as CrudAction | CustomAction] = permissionDetails;
} }
}); });
@@ -282,6 +296,19 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
category: "team", category: "team",
i18nKey: "pbac_action_list_members", i18nKey: "pbac_action_list_members",
descriptionI18nKey: "pbac_desc_list_team_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]: { [CustomAction.ChangeMemberRole]: {
description: "Change role of team members", description: "Change role of team members",
@@ -330,6 +357,20 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_list_organization_members", descriptionI18nKey: "pbac_desc_list_organization_members",
scope: [Scope.Organization], scope: [Scope.Organization],
dependsOn: ["organization.read"], 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]: { [CustomAction.Invite]: {
description: "Invite organization members", 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 { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client"; 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"; import { MembershipRole } from "@calcom/prisma/enums";
type WebhookGroup = { type WebhookGroup = {
@@ -162,7 +162,7 @@ export class WebhookRepository {
} }
// Add platform webhooks for admins // Add platform webhooks for admins
if (userRole === "ADMIN") { if (userRole === UserPermissionRole.ADMIN) {
const platformWebhooks = await prisma.webhook.findMany({ const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true }, where: { platform: true },
}); });
@@ -7,6 +7,7 @@ import { registerDecorator, validate, ValidatorConstraint } from "class-validato
const inputBookingFieldTypes = [ const inputBookingFieldTypes = [
"name", "name",
"splitName",
"email", "email",
"phone", "phone",
"address", "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 prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -18,9 +19,21 @@ type AddBulkToEventTypeHandler = {
export async function addMembersToEventTypesHandler({ ctx, input }: AddBulkToEventTypeHandler) { export async function addMembersToEventTypesHandler({ ctx, input }: AddBulkToEventTypeHandler) {
if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization // Check if user has permission to manage event types in the organization
if (!(await isOrganisationAdmin(ctx.user?.id, ctx.user.organizationId))) const permissionCheckService = new PermissionCheckService();
throw new TRPCError({ code: "UNAUTHORIZED" }); 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; const { eventTypeIds, teamIds, userIds } = input;
@@ -1,5 +1,6 @@
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import stripe from "@calcom/features/ee/payments/server/stripe"; 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 logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user"; import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -41,24 +42,20 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) =>
throw new NotAuthorizedError(); throw new NotAuthorizedError();
} }
// Validate user membership role // Validate user has permission to create teams in the organization
const userMembershipRole = await prisma.membership.findFirst({ const permissionCheckService = new PermissionCheckService();
where: { const hasPermission = await permissionCheckService.checkPermission({
userId: organizationOwner.id, userId: organizationOwner.id,
teamId: orgId, teamId: orgId,
role: { permission: "team.create",
in: ["OWNER", "ADMIN"], fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
// @TODO: not sure if this already setup earlier
// accepted: true,
},
select: {
role: true,
},
}); });
if (!userMembershipRole) { if (!hasPermission) {
log.error("User is not a member of the organization", safeStringify({ orgId, organizationOwner })); log.error(
"User is not authorized to create teams in the organization",
safeStringify({ orgId, organizationOwner })
);
throw new NotAuthorizedError(); throw new NotAuthorizedError();
} }
@@ -1,12 +1,52 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma"; 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"; import type { TDeleteTeamInputSchema } from "./deleteTeam.schema";
type DeleteOptions = { type DeleteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TDeleteTeamInputSchema; 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 // delete all memberships
await prisma.membership.deleteMany({ await prisma.membership.deleteMany({
where: { where: {
@@ -1,4 +1,6 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "../../../types"; import type { TrpcSessionUser } from "../../../types";
import type { TGetMembersInputSchema } from "./getMembers.schema"; import type { TGetMembersInputSchema } from "./getMembers.schema";
@@ -16,9 +18,21 @@ export const getMembersHandler = async ({ input, ctx }: CreateOptions) => {
if (!ctx.user.organizationId) return []; if (!ctx.user.organizationId) return [];
const isOrgPrivate = ctx.user.organization.isPrivate; 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({ const teamQuery = await prisma.team.findUnique({
where: { 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", () => ({ vi.mock("@calcom/prisma", () => ({
prisma, prisma,
default: prisma, // Add default export for db
})); }));
// Mock FeaturesRepository // Mock FeaturesRepository
@@ -22,9 +23,18 @@ vi.mock("@calcom/features/flags/features.repository", () => ({
vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({ vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({
getSpecificPermissions: vi.fn().mockResolvedValue({ getSpecificPermissions: vi.fn().mockResolvedValue({
listMembers: true, 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 // Mock UserRepository
vi.mock("@calcom/lib/server/repository/user", () => ({ vi.mock("@calcom/lib/server/repository/user", () => ({
UserRepository: vi.fn().mockImplementation(() => ({ UserRepository: vi.fn().mockImplementation(() => ({
@@ -65,6 +75,8 @@ describe("listMembersHandler", () => {
prisma.membership.findMany.mockResolvedValue([]); prisma.membership.findMany.mockResolvedValue([]);
prisma.membership.count.mockResolvedValue(0); prisma.membership.count.mockResolvedValue(0);
prisma.membership.findFirst.mockResolvedValue({ role: "ADMIN" }); 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 () => { 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 { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; 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 { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client"; 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." }); throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
} }
// Check PBAC permissions for listing organization members const permissionCheckService = new PermissionCheckService();
const permissions = await getSpecificPermissions({
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id, userId: ctx.user.id,
teamId: organizationId, teamId: organizationId,
resource: Resource.Organization, permission: ctx.user.organization.isPrivate
userRole: membership.role, ? "organization.listMembersPrivate"
actions: [CustomAction.ListMembers], : "organization.listMembers",
fallbackRoles: { fallbackRoles: ctx.user.organization.isPrivate
[CustomAction.ListMembers]: { ? [MembershipRole.ADMIN, MembershipRole.OWNER]
roles: ctx.user.organization.isPrivate : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}); });
if (!permissions[CustomAction.ListMembers]) { if (!hasPermission) {
return { return {
canUserGetMembers: false, canUserGetMembers: false,
rows: [], rows: [],
@@ -1,8 +1,9 @@
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; 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 { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -20,7 +21,21 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
if (!orgId) if (!orgId)
throw new TRPCError({ code: "UNAUTHORIZED", message: "You do not have an organization to upgrade" }); 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({ const prevTeam = await prisma.team.findUnique({
where: { where: {
@@ -66,7 +81,6 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
const { requestedSlug, ...newMetadata } = metadata.data; const { requestedSlug, ...newMetadata } = metadata.data;
let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>; let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>;
try { try {
updatedTeam = await prisma.team.update({ updatedTeam = await prisma.team.update({
where: { id: orgId }, where: { id: orgId },
@@ -15,6 +15,38 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types"; import type { TrpcSessionUser } from "../../../types";
import type { TUpdateInputSchema } from "./update.schema"; 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 = { type UpdateOptions = {
ctx: { ctx: {
user: NonNullable<TrpcSessionUser>; user: NonNullable<TrpcSessionUser>;
@@ -185,21 +217,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }), metadata: mergeMetadata({ ...input.metadata }),
}; };
if ( data.bannerUrl = await getBannerUrl(input.banner, currentOrgId);
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;
}
if ( if (
input.logoUrl && 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 { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client";
@@ -16,14 +19,18 @@ type ListMembersOptions = {
export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => { export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
const { prisma } = ctx; const { prisma } = ctx;
const { isOrgAdmin } = ctx.user.organization; const orgId = ctx.user.organizationId;
const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin;
if (!hasPermsToView) { // Check PBAC permissions for the organization if it's private
return { if (orgId) {
members: [], const hasPermsToView = await checkCanAccessOrgMembers(ctx, orgId);
nextCursor: undefined,
}; if (!hasPermsToView) {
return {
members: [],
nextCursor: undefined,
};
}
} }
const limit = input.limit ?? 10; 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; 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 { 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 { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { TeamRepository } from "@calcom/lib/server/repository/team"; import { TeamRepository } from "@calcom/lib/server/repository/team";
@@ -157,37 +162,22 @@ const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], team
if (!team) return false; 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 // Determine the resource type based on whether this is an org or team
const resource = isTargetingOrg ? Resource.Organization : Resource.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 // Check PBAC permissions for listing members
const permissions = await getSpecificPermissions({ const permissionCheckService = new PermissionCheckService();
return permissionCheckService.checkPermission({
userId: ctx.user.id, userId: ctx.user.id,
teamId: teamId, teamId: teamId,
resource: resource, permission: permissionString,
userRole: membership.role, fallbackRoles: team.isPrivate
actions: [CustomAction.ListMembers], ? [MembershipRole.ADMIN, MembershipRole.OWNER]
fallbackRoles: { : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
[CustomAction.ListMembers]: {
roles: team.isPrivate
? [MembershipRole.ADMIN, MembershipRole.OWNER]
: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}); });
return permissions[CustomAction.ListMembers];
}; };
export default listMembersHandler; export default listMembersHandler;