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