Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff5c8ce5f | ||
|
|
3be7fb9dd8 | ||
|
|
72dd970b6e | ||
|
|
f4d09ece43 | ||
|
|
239e6085ba | ||
|
|
b17eab3aeb | ||
|
|
752f7fcde0 | ||
|
|
f6b061f787 | ||
|
|
cc8a1290ee | ||
|
|
b7cf893169 | ||
|
|
1458cacdbb |
Vendored
+51
-9
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -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>{" "}
|
||||||
|
|||||||
+3
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-1
@@ -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 === "*";
|
||||||
|
|||||||
+15
-1
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-1
@@ -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],
|
||||||
|
|||||||
+17
-2
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-1
@@ -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,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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
+1
@@ -7,6 +7,7 @@ import { registerDecorator, validate, ValidatorConstraint } from "class-validato
|
|||||||
|
|
||||||
const inputBookingFieldTypes = [
|
const inputBookingFieldTypes = [
|
||||||
"name",
|
"name",
|
||||||
|
"splitName",
|
||||||
"email",
|
"email",
|
||||||
"phone",
|
"phone",
|
||||||
"address",
|
"address",
|
||||||
|
|||||||
+16
@@ -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
|
||||||
+17
-4
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user