feat: event type pbac (#22618)
* fix members page crash with pbac feature flag * invite member to org backend * update org permissions * feat: org profile update settings * org general page * remove redudant me call * privacy page * add attributes to pbac * dync + sso on organization permissions * add tests for resource-permission util * pass permissions to attributes\ * restore invite members * update org update and attribute backends * fix type errors * fix orgId * fix types attempt two * fix types attempt two * fix type error * show/hide team event types based on eventType.read permission * fix dupe string in i18n * fix tests * fix team-dsync * update session to use profile * use profile metadata to get orgRolew * fix dsync * fix typing * Event type create pbac check * fix the readonly permission check. WIP * refactor getUserEventTypes * fix test * add generate util for update * update router to use new generator function for pbac procedures * update transform utils * fix members being able to update on UI * fix allSettled * fix fallback permissions * fix permission logic * fix compare memebrship type error * fix unit test error * fix manage test error * fix nested permissions * filter based on canView permission in getUserEventGroups * Update packages/trpc/server/routers/viewer/eventTypes/utils/EventTypeGroupFilter.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix tests * fix tests * feat: Adding workflow permission checks inside of eventTypes (#23038) * add permission checks for workflows * wip getall active workflows * check eventType.update permission on activiating workflow on event type * pass permissions to context and use context in client * remove local parsePermissionString and use registery * Use new scoped function * default scope to orgs * use object values for workflow permissions * fix type errors * Fix permission traversal + fix all state caluclation * fix eventType group to use permissions.canRead * fix: update failing PBAC unit tests and resolve ESLint warnings - Fix getUserEventGroups test: MEMBER role should not have update permissions in fallback scenario - Fix EventTypeGroupFilter test: add missing canRead property to mock permissions map - Replace 'as any' type assertions with proper 'as unknown as' type assertions to resolve ESLint warnings Both tests now pass and align with PBAC permission model where MEMBER role has read-only access to event types. Co-Authored-By: sean@cal.com <Sean@brydon.io> * add pbac to _heavy router * Merge main * restore versions * Parallelizable members query * fix nits * fix create * Restore lock file * Fix import from merge artifact --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
sean@cal.com
coderabbitai[bot]
Keith Williams
Devin AI
parent
eaa1ad6578
commit
76ced2d3b8
@@ -8,6 +8,10 @@ import { z } from "zod";
|
||||
|
||||
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -40,6 +44,95 @@ const getCachedEventType = unstable_cache(
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const getEventPermissions = async (userId: number, teamId: number | null) => {
|
||||
// Personal event - has all perms
|
||||
if (!teamId)
|
||||
return {
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) throw new Error("Membership not found");
|
||||
|
||||
const [eventTypePermissions, workflowPermissions] = await Promise.all([
|
||||
getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.EventType,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.Workflow,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
eventTypes: {
|
||||
canRead: eventTypePermissions.canRead,
|
||||
canCreate: eventTypePermissions.canCreate,
|
||||
canUpdate: eventTypePermissions.canEdit,
|
||||
canDelete: eventTypePermissions.canDelete,
|
||||
},
|
||||
workflows: {
|
||||
canRead: workflowPermissions.canRead,
|
||||
canCreate: workflowPermissions.canCreate,
|
||||
canUpdate: workflowPermissions.canEdit,
|
||||
canDelete: workflowPermissions.canDelete,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ServerPage = async ({ params }: PageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -59,7 +152,10 @@ const ServerPage = async ({ params }: PageProps) => {
|
||||
throw new Error("This event type does not exist");
|
||||
}
|
||||
|
||||
return <EventTypeWebWrapper data={data} id={eventTypeId} />;
|
||||
// Fetch permissions for the event type's team
|
||||
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
|
||||
|
||||
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
+6
-6
@@ -107,9 +107,9 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
|
||||
const { isAdvancedMode, permissions, color } = form.watch();
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
const { filteredResources, scopedRegistry } = useMemo(() => {
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
return Object.keys(scopedRegistry).filter((resource) =>
|
||||
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
|
||||
t(
|
||||
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
|
||||
?.i18nKey || ""
|
||||
@@ -117,6 +117,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return { filteredResources, scopedRegistry };
|
||||
}, [searchQuery, t, scope]);
|
||||
|
||||
const createMutation = trpc.viewer.pbac.createRole.useMutation({
|
||||
@@ -212,7 +213,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
disabled={isSystemRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredResources.map((resource) => (
|
||||
<AdvancedPermissionGroup
|
||||
key={resource}
|
||||
@@ -222,7 +222,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
))}{" "}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted rounded-xl p-1">
|
||||
@@ -237,7 +237,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
|
||||
{Object.keys(getPermissionsForScope(scope)).map((resource) => (
|
||||
{Object.keys(scopedRegistry).map((resource) => (
|
||||
<SimplePermissionItem
|
||||
key={resource}
|
||||
resource={resource}
|
||||
@@ -247,7 +247,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
Scope,
|
||||
PERMISSION_REGISTRY,
|
||||
getPermissionsForScope,
|
||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -21,11 +25,13 @@ export function SimplePermissionItem({
|
||||
permissions,
|
||||
onChange,
|
||||
disabled,
|
||||
scope,
|
||||
scope = Scope.Organization,
|
||||
}: SimplePermissionItemProps) {
|
||||
const { t } = useLocale();
|
||||
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
|
||||
const registry = scopedRegistry || PERMISSION_REGISTRY;
|
||||
const isAllResources = resource === "*";
|
||||
const options = isAllResources
|
||||
? [
|
||||
@@ -41,7 +47,7 @@ export function SimplePermissionItem({
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-default text-sm font-medium leading-none">
|
||||
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
|
||||
{t(registry[resource as Resource]._resource?.i18nKey || resource)}
|
||||
</span>
|
||||
<ToggleGroup
|
||||
onValueChange={(val) => {
|
||||
|
||||
+17
-10
@@ -1,8 +1,5 @@
|
||||
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
PERMISSION_REGISTRY,
|
||||
getPermissionsForScope,
|
||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getPermissionsForScope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
getTransitiveDependencies,
|
||||
getTransitiveDependents,
|
||||
@@ -52,7 +49,8 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
return permissions.includes("*.*") ? "all" : "none";
|
||||
}
|
||||
|
||||
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
|
||||
if (!resourceConfig) return "none";
|
||||
|
||||
// Check if global all permissions (*.*) is present
|
||||
@@ -111,7 +109,19 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
allResourcePerms = Object.keys(resourceConfig)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
.map((action) => `${resource}.${action}`);
|
||||
|
||||
// Add the resource permissions
|
||||
newPermissions.push(...allResourcePerms);
|
||||
|
||||
// Add all transitive dependencies for each permission
|
||||
allResourcePerms.forEach((perm) => {
|
||||
const dependencies = getTransitiveDependencies(perm, scope);
|
||||
dependencies.forEach((dep) => {
|
||||
if (!newPermissions.includes(dep)) {
|
||||
newPermissions.push(dep);
|
||||
}
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -132,15 +142,12 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
// First, remove *.* since we're modifying individual permissions
|
||||
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
|
||||
|
||||
// Parse the permission to get resource and action
|
||||
const [resource, action] = permission.split(".");
|
||||
|
||||
if (enabled) {
|
||||
// Add the requested permission
|
||||
newPermissions.push(permission);
|
||||
|
||||
// Add all transitive dependencies
|
||||
const dependencies = getTransitiveDependencies(permission);
|
||||
const dependencies = getTransitiveDependencies(permission, scope);
|
||||
dependencies.forEach((dependency) => {
|
||||
if (!newPermissions.includes(dependency)) {
|
||||
newPermissions.push(dependency);
|
||||
@@ -151,7 +158,7 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
newPermissions = newPermissions.filter((p) => p !== permission);
|
||||
|
||||
// Remove all transitive dependents
|
||||
const dependents = getTransitiveDependents(permission);
|
||||
const dependents = getTransitiveDependents(permission, scope);
|
||||
dependents.forEach((dependent) => {
|
||||
newPermissions = newPermissions.filter((p) => p !== dependent);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -954,6 +954,24 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
||||
userEventGroupsData?.profiles
|
||||
?.filter((profile) => !profile.readOnly)
|
||||
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||
?.filter((profile) => {
|
||||
// For personal profiles (teamId is null), always allow creation
|
||||
if (!profile.teamId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For team profiles, check if user has eventType.create permission
|
||||
// This will be populated by the server-side PBAC check
|
||||
// Fallback to role-based check (admin/owner) if canCreateEventTypes is not set
|
||||
if (profile.canCreateEventTypes !== undefined) {
|
||||
return profile.canCreateEventTypes;
|
||||
}
|
||||
|
||||
// Fallback: allow admin and owner roles
|
||||
return (
|
||||
profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER
|
||||
);
|
||||
})
|
||||
?.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
|
||||
@@ -25,7 +25,10 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
|
||||
|
||||
type PartialWorkflowType = Pick<WorkflowType, "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly">;
|
||||
type PartialWorkflowType = Pick<
|
||||
WorkflowType,
|
||||
"name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly" | "permissions"
|
||||
>;
|
||||
|
||||
type ItemProps = {
|
||||
workflow: PartialWorkflowType;
|
||||
@@ -42,14 +45,6 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||
const { workflow, eventType, isActive } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
|
||||
workflow.activeOn?.map((active) => {
|
||||
if (active.eventType) {
|
||||
return active.eventType.id;
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
|
||||
@@ -137,7 +132,7 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
{workflow.permissions?.canUpdate && (
|
||||
<div className="flex-none">
|
||||
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
|
||||
<Button type="button" color="minimal" className="mr-4" EndIcon="external-link">
|
||||
@@ -162,7 +157,7 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||
)}
|
||||
<Switch
|
||||
checked={isActive}
|
||||
disabled={workflow.readOnly}
|
||||
disabled={!workflow.permissions?.canUpdate}
|
||||
onCheckedChange={() => {
|
||||
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
|
||||
}}
|
||||
@@ -230,7 +225,7 @@ function EventWorkflowsTab(props: Props) {
|
||||
|
||||
const createMutation = trpc.viewer.workflows.create.useMutation({
|
||||
onSuccess: async ({ workflow }) => {
|
||||
await router.replace(`/workflows/${workflow.id}?eventTypeId=${eventType.id}`);
|
||||
router.replace(`/workflows/${workflow.id}?eventTypeId=${eventType.id}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useWorkflowPermission } from "../hooks/useEventPermission";
|
||||
|
||||
interface WorkflowTabPermissionGuardProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
export const WorkflowTabPermissionGuard = ({
|
||||
children,
|
||||
fallback = null,
|
||||
}: WorkflowTabPermissionGuardProps) => {
|
||||
const { hasPermission } = useWorkflowPermission("workflow.read");
|
||||
|
||||
if (!hasPermission) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { createStore, useStore } from "zustand";
|
||||
|
||||
export interface EventPermissions {
|
||||
eventTypes: {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
workflows: {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface EventPermissionStore {
|
||||
permissions: EventPermissions;
|
||||
setPermissions: (permissions: EventPermissions) => void;
|
||||
hasEventTypePermission: (permission: keyof EventPermissions["eventTypes"]) => boolean;
|
||||
hasWorkflowPermission: (permission: keyof EventPermissions["workflows"]) => boolean;
|
||||
}
|
||||
|
||||
const createEventPermissionStore = (
|
||||
initialPermissions: EventPermissions = {
|
||||
eventTypes: {
|
||||
canRead: false,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
workflows: {
|
||||
canRead: false,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
}
|
||||
) =>
|
||||
createStore<EventPermissionStore>()((set, get) => ({
|
||||
permissions: initialPermissions,
|
||||
setPermissions: (permissions) => set({ permissions }),
|
||||
hasEventTypePermission: (permission) => {
|
||||
const { permissions } = get();
|
||||
return permissions.eventTypes[permission];
|
||||
},
|
||||
hasWorkflowPermission: (permission) => {
|
||||
const { permissions } = get();
|
||||
return permissions.workflows[permission];
|
||||
},
|
||||
}));
|
||||
|
||||
type EventPermissionStoreApi = ReturnType<typeof createEventPermissionStore>;
|
||||
|
||||
const EventPermissionContext = createContext<EventPermissionStoreApi | undefined>(undefined);
|
||||
|
||||
interface EventPermissionProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialPermissions: EventPermissions;
|
||||
}
|
||||
|
||||
export const EventPermissionProvider = ({ children, initialPermissions }: EventPermissionProviderProps) => {
|
||||
const store = createEventPermissionStore(initialPermissions);
|
||||
|
||||
return <EventPermissionContext.Provider value={store}>{children}</EventPermissionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useEventPermissionStore = <T,>(selector: (store: EventPermissionStore) => T): T => {
|
||||
const eventPermissionStoreContext = useContext(EventPermissionContext);
|
||||
|
||||
if (!eventPermissionStoreContext) {
|
||||
throw new Error("useEventPermissionStore must be used within EventPermissionProvider");
|
||||
}
|
||||
|
||||
return useStore(eventPermissionStoreContext, selector);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import type { EventPermissions } from "../context/EventPermissionContext";
|
||||
import { useEventPermissionStore } from "../context/EventPermissionContext";
|
||||
|
||||
export const useEventTypePermission = (permission: keyof EventPermissions["eventTypes"]) => {
|
||||
return useEventPermissionStore((state) => ({
|
||||
hasPermission: state.hasEventTypePermission(permission),
|
||||
permissions: state.permissions.eventTypes,
|
||||
}));
|
||||
};
|
||||
|
||||
export const useWorkflowPermission = (permission: keyof EventPermissions["workflows"]) => {
|
||||
return useEventPermissionStore((state) => ({
|
||||
hasPermission: state.hasWorkflowPermission(permission),
|
||||
permissions: state.permissions.workflows,
|
||||
}));
|
||||
};
|
||||
|
||||
export const useAllEventPermissions = () => {
|
||||
return useEventPermissionStore((state) => state.permissions);
|
||||
};
|
||||
|
||||
// Convenience hooks for common permissions
|
||||
export const useCanCreateEventTypes = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.eventTypes.canCreate);
|
||||
};
|
||||
|
||||
export const useCanUpdateEventTypes = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.eventTypes.canUpdate);
|
||||
};
|
||||
|
||||
export const useCanDeleteEventTypes = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.eventTypes.canDelete);
|
||||
};
|
||||
|
||||
export const useCanReadWorkflows = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.workflows.canRead);
|
||||
};
|
||||
|
||||
export const useCanCreateWorkflows = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.workflows.canCreate);
|
||||
};
|
||||
|
||||
export const useCanUpdateWorkflows = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.workflows.canUpdate);
|
||||
};
|
||||
|
||||
export const useCanDeleteWorkflows = () => {
|
||||
return useEventPermissionStore((state) => state.permissions.workflows.canDelete);
|
||||
};
|
||||
@@ -264,7 +264,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_invite",
|
||||
descriptionI18nKey: "pbac_desc_invite_team_members",
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
dependsOn: ["team.read", "team.listMembers", "role.read"],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
description: "Remove team members",
|
||||
@@ -284,7 +284,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_change_member_role",
|
||||
descriptionI18nKey: "pbac_desc_change_team_member_role",
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
dependsOn: ["team.read", "team.listMembers", "role.read"],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
description: "Impersonate team members",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { CrudAction, type CustomAction, PERMISSION_REGISTRY } from "../domain/types/permission-registry";
|
||||
import {
|
||||
CrudAction,
|
||||
type CustomAction,
|
||||
PERMISSION_REGISTRY,
|
||||
Scope,
|
||||
getPermissionsForScope,
|
||||
} from "../domain/types/permission-registry";
|
||||
|
||||
/**
|
||||
* Helper function to split permission string into resource and action
|
||||
@@ -22,15 +28,18 @@ const splitPermission = (permission: string): { resource: string; action: string
|
||||
* Generic permission graph traversal function using BFS
|
||||
* @param startPermission The permission to start traversal from
|
||||
* @param direction Whether to find dependencies or dependents
|
||||
* @param scope The scope to use for permission registry (defaults to Organization)
|
||||
* @returns Array of related permissions (excluding the start permission itself)
|
||||
*/
|
||||
export const traversePermissions = (
|
||||
startPermission: string,
|
||||
direction: "dependencies" | "dependents"
|
||||
direction: "dependencies" | "dependents",
|
||||
scope: Scope = Scope.Organization
|
||||
): string[] => {
|
||||
const visited = new Set<string>();
|
||||
const result = new Set<string>();
|
||||
const queue: string[] = [startPermission];
|
||||
const registry = getPermissionsForScope(scope);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentPermission = queue.shift();
|
||||
@@ -46,7 +55,7 @@ export const traversePermissions = (
|
||||
if (direction === "dependencies") {
|
||||
// Find what the current permission depends on
|
||||
const { resource, action } = splitPermission(currentPermission);
|
||||
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||
const resourceConfig = registry[resource as keyof typeof registry];
|
||||
|
||||
if (resourceConfig && resourceConfig[action as CrudAction | CustomAction]) {
|
||||
const permissionDetails = resourceConfig[action as CrudAction | CustomAction];
|
||||
@@ -70,7 +79,7 @@ export const traversePermissions = (
|
||||
}
|
||||
} else {
|
||||
// Find what depends on the current permission
|
||||
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
|
||||
Object.entries(registry).forEach(([resource, config]) => {
|
||||
Object.entries(config).forEach(([action, details]) => {
|
||||
if (action.startsWith("_")) return; // Skip internal keys
|
||||
|
||||
@@ -108,17 +117,19 @@ export const traversePermissions = (
|
||||
/**
|
||||
* Get all permissions that the given permission transitively depends on
|
||||
* @param permission The permission to find dependencies for
|
||||
* @param scope The scope to use for permission registry (defaults to Organization)
|
||||
* @returns Array of dependency permissions
|
||||
*/
|
||||
export const getTransitiveDependencies = (permission: string): string[] => {
|
||||
return traversePermissions(permission, "dependencies");
|
||||
export const getTransitiveDependencies = (permission: string, scope: Scope = Scope.Organization): string[] => {
|
||||
return traversePermissions(permission, "dependencies", scope);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all permissions that transitively depend on the given permission
|
||||
* @param permission The permission to find dependents for
|
||||
* @param scope The scope to use for permission registry (defaults to Organization)
|
||||
* @returns Array of dependent permissions
|
||||
*/
|
||||
export const getTransitiveDependents = (permission: string): string[] => {
|
||||
return traversePermissions(permission, "dependents");
|
||||
export const getTransitiveDependents = (permission: string, scope: Scope = Scope.Organization): string[] => {
|
||||
return traversePermissions(permission, "dependents", scope);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ type Props = {
|
||||
team: EventTypeSetupProps["team"];
|
||||
eventTypeApps?: EventTypeApps;
|
||||
allActiveWorkflows?: Workflow[];
|
||||
canReadWorkflows?: boolean;
|
||||
};
|
||||
export const useTabsNavigations = ({
|
||||
formMethods,
|
||||
@@ -31,6 +32,7 @@ export const useTabsNavigations = ({
|
||||
team,
|
||||
eventTypeApps,
|
||||
allActiveWorkflows,
|
||||
canReadWorkflows = false,
|
||||
}: Props) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -79,6 +81,7 @@ export const useTabsNavigations = ({
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
availability,
|
||||
canReadWorkflows,
|
||||
});
|
||||
|
||||
if (!requirePayment) {
|
||||
@@ -166,6 +169,7 @@ export const useTabsNavigations = ({
|
||||
watchSchedulingType,
|
||||
watchChildrenCount,
|
||||
activeWebhooksNumber,
|
||||
canReadWorkflows,
|
||||
]);
|
||||
|
||||
return { tabsNavigation: EventTypeTabs };
|
||||
@@ -180,6 +184,7 @@ type getNavigationProps = {
|
||||
enabledWorkflowsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
availability: AvailabilityOption | undefined;
|
||||
canReadWorkflows: boolean;
|
||||
};
|
||||
|
||||
function getNavigation({
|
||||
@@ -190,10 +195,11 @@ function getNavigation({
|
||||
enabledAppsNumber,
|
||||
installedAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
canReadWorkflows,
|
||||
}: getNavigationProps) {
|
||||
const duration = multipleDuration?.map((duration) => ` ${duration}`) || length;
|
||||
|
||||
return [
|
||||
const baseNavigation: VerticalTabItemProps[] = [
|
||||
{
|
||||
name: t("basics"),
|
||||
href: `/event-types/${id}?tabName=setup`,
|
||||
@@ -223,12 +229,18 @@ function getNavigation({
|
||||
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
||||
"data-testid": "apps",
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
// Only add workflows tab if user has permission to read workflows
|
||||
if (canReadWorkflows) {
|
||||
baseNavigation.push({
|
||||
name: t("workflows"),
|
||||
href: `/event-types/${id}?tabName=workflows`,
|
||||
icon: "zap",
|
||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||
"data-testid": "workflows",
|
||||
},
|
||||
] satisfies VerticalTabItemProps[];
|
||||
});
|
||||
}
|
||||
|
||||
return baseNavigation;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { EventType as EventTypeComponent } from "@calcom/features/eventtypes/components/EventType";
|
||||
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
|
||||
import { EventPermissionProvider } from "@calcom/features/pbac/client/context/EventPermissionContext";
|
||||
import { useWorkflowPermission } from "@calcom/features/pbac/client/hooks/useEventPermission";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
@@ -29,6 +31,21 @@ import { useEventTypeForm } from "../hooks/useEventTypeForm";
|
||||
import { useHandleRouteChange } from "../hooks/useHandleRouteChange";
|
||||
import { useTabsNavigations } from "../hooks/useTabsNavigations";
|
||||
|
||||
type EventPermissions = {
|
||||
eventTypes: {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
workflows: {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const ManagedEventTypeDialog = dynamic(
|
||||
() => import("@calcom/features/eventtypes/components/dialogs/ManagedEventDialog")
|
||||
);
|
||||
@@ -91,21 +108,47 @@ const EventAITab = dynamic(() =>
|
||||
export type EventTypeWebWrapperProps = {
|
||||
id: number;
|
||||
data: RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||
permissions?: EventPermissions;
|
||||
};
|
||||
|
||||
export const EventTypeWebWrapper = ({ id, data: serverFetchedData }: EventTypeWebWrapperProps) => {
|
||||
export const EventTypeWebWrapper = ({
|
||||
id,
|
||||
data: serverFetchedData,
|
||||
permissions = {
|
||||
eventTypes: {
|
||||
canRead: false,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
workflows: {
|
||||
canRead: false,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
},
|
||||
}: EventTypeWebWrapperProps) => {
|
||||
const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery(
|
||||
{ id },
|
||||
{ enabled: !serverFetchedData }
|
||||
);
|
||||
|
||||
if (serverFetchedData) {
|
||||
return <EventTypeWeb {...serverFetchedData} id={id} />;
|
||||
return (
|
||||
<EventPermissionProvider initialPermissions={permissions}>
|
||||
<EventTypeWeb {...serverFetchedData} id={id} />
|
||||
</EventPermissionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventTypeQueryData) return null;
|
||||
|
||||
return <EventTypeWeb {...eventTypeQueryData} id={id} />;
|
||||
return (
|
||||
<EventPermissionProvider initialPermissions={permissions}>
|
||||
<EventTypeWeb {...eventTypeQueryData} id={id} />
|
||||
</EventPermissionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeWeb = ({
|
||||
@@ -131,6 +174,9 @@ const EventTypeWeb = ({
|
||||
teamId: eventType.team?.id || eventType.parent?.teamId,
|
||||
onlyInstalled: true,
|
||||
});
|
||||
|
||||
// Check workflow permissions
|
||||
const { hasPermission: canReadWorkflows } = useWorkflowPermission("canRead");
|
||||
const updateMutation = trpc.viewer.eventTypes.heavy.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
const currentValues = form.getValues();
|
||||
@@ -241,11 +287,12 @@ const EventTypeWeb = ({
|
||||
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
|
||||
recurring: <EventRecurringTab eventType={eventType} />,
|
||||
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
|
||||
workflows: allActiveWorkflows ? (
|
||||
<EventWorkflowsTab eventType={eventType} workflows={allActiveWorkflows} />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
workflows:
|
||||
allActiveWorkflows && canReadWorkflows ? (
|
||||
<EventWorkflowsTab eventType={eventType} workflows={allActiveWorkflows} />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
webhooks: <EventWebhooksTab eventType={eventType} />,
|
||||
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
|
||||
} as const;
|
||||
@@ -358,6 +405,7 @@ const EventTypeWeb = ({
|
||||
team,
|
||||
eventTypeApps,
|
||||
allActiveWorkflows,
|
||||
canReadWorkflows,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getUserEventGroups } from "../getUserEventGroups.handler";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/membership", () => ({
|
||||
MembershipRepository: {
|
||||
findAllByUpIdIncludeTeam: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
||||
ProfileRepository: {
|
||||
findByUpId: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFilterTeamsByEventTypeReadPermission = vi.fn();
|
||||
|
||||
vi.mock("../teamAccessUseCase", () => ({
|
||||
TeamAccessUseCase: vi.fn().mockImplementation(() => ({
|
||||
filterTeamsByEventTypeReadPermission: mockFilterTeamsByEventTypeReadPermission,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/getBookerUrl/server", () => ({
|
||||
getBookerBaseUrl: vi.fn().mockResolvedValue("https://cal.com"),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/getBookerUrl/client", () => ({
|
||||
getBookerBaseUrlSync: vi.fn().mockReturnValue("https://cal.com"),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/getAvatarUrl", () => ({
|
||||
getUserAvatarUrl: vi.fn().mockReturnValue("https://avatar.com/user.jpg"),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/defaultAvatarImage", () => ({
|
||||
getPlaceholderAvatar: vi.fn().mockReturnValue("https://avatar.com/placeholder.jpg"),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({
|
||||
getResourcePermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getUserEventGroups", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
profile: {
|
||||
upId: "user-123",
|
||||
},
|
||||
} as unknown as NonNullable<Parameters<typeof getUserEventGroups>[0]["ctx"]["user"]>;
|
||||
|
||||
const mockProfile = {
|
||||
id: 1,
|
||||
username: "testuser",
|
||||
name: "Test User",
|
||||
avatarUrl: null,
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
} as unknown as NonNullable<
|
||||
Awaited<ReturnType<typeof import("@calcom/lib/server/repository/profile").ProfileRepository.findByUpId>>
|
||||
>;
|
||||
|
||||
const mockCtx = {
|
||||
user: mockUser,
|
||||
prisma: {} as PrismaClient,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Basic functionality", () => {
|
||||
it("should throw TRPCError when profile is not found", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: null,
|
||||
})
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should return user event groups when no filters are applied", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
const { MembershipRepository } = await import("@calcom/lib/server/repository/membership");
|
||||
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(mockProfile);
|
||||
vi.mocked(MembershipRepository.findAllByUpIdIncludeTeam).mockResolvedValue([]);
|
||||
mockFilterTeamsByEventTypeReadPermission.mockResolvedValue([]);
|
||||
|
||||
const result = await getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("eventTypeGroups");
|
||||
expect(result).toHaveProperty("profiles");
|
||||
expect(result.eventTypeGroups).toHaveLength(1);
|
||||
expect(result.eventTypeGroups[0]).toMatchObject({
|
||||
teamId: null,
|
||||
membershipRole: null,
|
||||
profile: {
|
||||
slug: "testuser",
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team memberships", () => {
|
||||
it("should include team events when team memberships exist", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
const { MembershipRepository } = await import("@calcom/lib/server/repository/membership");
|
||||
const { getResourcePermissions } = await import("@calcom/features/pbac/lib/resource-permissions");
|
||||
|
||||
const mockTeamMembership = {
|
||||
id: 1,
|
||||
teamId: 100,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
customRoleId: null,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team: {
|
||||
id: 100,
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
logoUrl: null,
|
||||
parentId: null,
|
||||
parent: null,
|
||||
metadata: {},
|
||||
},
|
||||
} as unknown as NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof import("@calcom/lib/server/repository/membership").MembershipRepository.findAllByUpIdIncludeTeam
|
||||
>
|
||||
>
|
||||
>[0];
|
||||
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(mockProfile);
|
||||
vi.mocked(MembershipRepository.findAllByUpIdIncludeTeam).mockResolvedValue([mockTeamMembership]);
|
||||
mockFilterTeamsByEventTypeReadPermission.mockResolvedValue([mockTeamMembership]);
|
||||
|
||||
vi.mocked(getResourcePermissions).mockResolvedValue({
|
||||
canCreate: false,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canRead: true,
|
||||
});
|
||||
|
||||
const result = await getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: null,
|
||||
});
|
||||
|
||||
expect(result.eventTypeGroups).toHaveLength(2); // User + Team
|
||||
expect(result.eventTypeGroups[1]).toMatchObject({
|
||||
teamId: 100,
|
||||
profile: {
|
||||
name: "Test Team",
|
||||
slug: "team/test-team",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Permissions", () => {
|
||||
it("should handle PBAC permissions correctly", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
const { MembershipRepository } = await import("@calcom/lib/server/repository/membership");
|
||||
const { getResourcePermissions } = await import("@calcom/features/pbac/lib/resource-permissions");
|
||||
|
||||
const mockTeamMembership = {
|
||||
id: 1,
|
||||
teamId: 100,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
customRoleId: null,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team: {
|
||||
id: 100,
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
logoUrl: null,
|
||||
parentId: null,
|
||||
parent: null,
|
||||
metadata: {},
|
||||
},
|
||||
} as unknown as NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof import("@calcom/lib/server/repository/membership").MembershipRepository.findAllByUpIdIncludeTeam
|
||||
>
|
||||
>
|
||||
>[0];
|
||||
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(mockProfile);
|
||||
vi.mocked(MembershipRepository.findAllByUpIdIncludeTeam).mockResolvedValue([mockTeamMembership]);
|
||||
mockFilterTeamsByEventTypeReadPermission.mockResolvedValue([mockTeamMembership]);
|
||||
|
||||
vi.mocked(getResourcePermissions).mockResolvedValue({
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canRead: true,
|
||||
});
|
||||
|
||||
const result = await getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: null,
|
||||
});
|
||||
|
||||
expect(result.profiles[1]).toMatchObject({
|
||||
canCreateEventTypes: true,
|
||||
canUpdateEventTypes: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to role-based permissions when PBAC fails", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
const { MembershipRepository } = await import("@calcom/lib/server/repository/membership");
|
||||
const { getResourcePermissions } = await import("@calcom/features/pbac/lib/resource-permissions");
|
||||
|
||||
const mockTeamMembership = {
|
||||
id: 1,
|
||||
teamId: 100,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
customRoleId: null,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team: {
|
||||
id: 100,
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
logoUrl: null,
|
||||
parentId: null,
|
||||
parent: null,
|
||||
metadata: {},
|
||||
},
|
||||
} as unknown as NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof import("@calcom/lib/server/repository/membership").MembershipRepository.findAllByUpIdIncludeTeam
|
||||
>
|
||||
>
|
||||
>[0];
|
||||
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(mockProfile);
|
||||
vi.mocked(MembershipRepository.findAllByUpIdIncludeTeam).mockResolvedValue([mockTeamMembership]);
|
||||
mockFilterTeamsByEventTypeReadPermission.mockResolvedValue([mockTeamMembership]);
|
||||
|
||||
vi.mocked(getResourcePermissions).mockRejectedValue(new Error("PBAC failed"));
|
||||
|
||||
const result = await getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: null,
|
||||
});
|
||||
|
||||
// Member role should not have create/update/delete permissions
|
||||
expect(result.profiles[1]).toMatchObject({
|
||||
canCreateEventTypes: false,
|
||||
canUpdateEventTypes: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization handling", () => {
|
||||
it("should handle organization locked event types", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
const { MembershipRepository } = await import("@calcom/lib/server/repository/membership");
|
||||
|
||||
const mockProfileWithOrg = {
|
||||
...mockProfile,
|
||||
organization: {
|
||||
organizationSettings: {
|
||||
lockEventTypeCreationForUsers: true,
|
||||
},
|
||||
},
|
||||
} as unknown as NonNullable<
|
||||
Awaited<
|
||||
ReturnType<typeof import("@calcom/lib/server/repository/profile").ProfileRepository.findByUpId>
|
||||
>
|
||||
>;
|
||||
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(mockProfileWithOrg);
|
||||
vi.mocked(MembershipRepository.findAllByUpIdIncludeTeam).mockResolvedValue([]);
|
||||
mockFilterTeamsByEventTypeReadPermission.mockResolvedValue([]);
|
||||
|
||||
const result = await getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: null,
|
||||
});
|
||||
|
||||
expect(result.eventTypeGroups[0].profile.eventTypesLockedByOrg).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routing forms", () => {
|
||||
it("should handle routing forms slug format", async () => {
|
||||
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
|
||||
const { MembershipRepository } = await import("@calcom/lib/server/repository/membership");
|
||||
const { getResourcePermissions } = await import("@calcom/features/pbac/lib/resource-permissions");
|
||||
|
||||
const mockTeamMembership = {
|
||||
id: 1,
|
||||
teamId: 100,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
customRoleId: null,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team: {
|
||||
id: 100,
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
logoUrl: null,
|
||||
parentId: null,
|
||||
parent: null,
|
||||
metadata: {},
|
||||
},
|
||||
} as unknown as NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof import("@calcom/lib/server/repository/membership").MembershipRepository.findAllByUpIdIncludeTeam
|
||||
>
|
||||
>
|
||||
>[0];
|
||||
|
||||
vi.mocked(ProfileRepository.findByUpId).mockResolvedValue(mockProfile);
|
||||
vi.mocked(MembershipRepository.findAllByUpIdIncludeTeam).mockResolvedValue([mockTeamMembership]);
|
||||
mockFilterTeamsByEventTypeReadPermission.mockResolvedValue([mockTeamMembership]);
|
||||
|
||||
vi.mocked(getResourcePermissions).mockResolvedValue({
|
||||
canCreate: false,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canRead: true,
|
||||
});
|
||||
|
||||
const result = await getUserEventGroups({
|
||||
ctx: mockCtx,
|
||||
input: {
|
||||
forRoutingForms: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.eventTypeGroups[1].profile.slug).toBe("team/test-team");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { logP } from "@calcom/lib/perf";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
@@ -10,23 +11,7 @@ import { ZGetHashedLinkInputSchema } from "./getHashedLink.schema";
|
||||
import { ZGetHashedLinksInputSchema } from "./getHashedLinks.schema";
|
||||
import { ZGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema";
|
||||
import { get } from "./procedures/get";
|
||||
import { eventOwnerProcedure } from "./util";
|
||||
|
||||
type BookingsRouterHandlerCache = {
|
||||
getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler;
|
||||
getUserEventGroups?: typeof import("./getUserEventGroups.handler").getUserEventGroups;
|
||||
getEventTypesFromGroup?: typeof import("./getEventTypesFromGroup.handler").getEventTypesFromGroup;
|
||||
getTeamAndEventTypeOptions?: typeof import("./getTeamAndEventTypeOptions.handler").getTeamAndEventTypeOptions;
|
||||
list?: typeof import("./list.handler").listHandler;
|
||||
listWithTeam?: typeof import("./listWithTeam.handler").listWithTeamHandler;
|
||||
get?: typeof import("./get.handler").getHandler;
|
||||
delete?: typeof import("./delete.handler").deleteHandler;
|
||||
bulkEventFetch?: typeof import("./bulkEventFetch.handler").bulkEventFetchHandler;
|
||||
bulkUpdateToDefaultLocation?: typeof import("./bulkUpdateToDefaultLocation.handler").bulkUpdateToDefaultLocationHandler;
|
||||
};
|
||||
|
||||
// Init the handler cache
|
||||
const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
|
||||
import { createEventPbacProcedure } from "./util";
|
||||
|
||||
export const eventTypesRouter = router({
|
||||
// REVIEW: What should we name this procedure?
|
||||
@@ -111,14 +96,16 @@ export const eventTypesRouter = router({
|
||||
|
||||
get,
|
||||
|
||||
delete: eventOwnerProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { deleteHandler } = await import("./delete.handler");
|
||||
delete: createEventPbacProcedure("eventType.delete", [MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
.input(ZDeleteInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { deleteHandler } = await import("./delete.handler");
|
||||
|
||||
return deleteHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
return deleteHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
bulkEventFetch: authedProcedure.query(async ({ ctx }) => {
|
||||
const { bulkEventFetchHandler } = await import("./bulkEventFetch.handler");
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
// import { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TEventTypeInputSchema } from "./getByViewer.schema";
|
||||
import { TeamAccessUseCase } from "./teamAccessUseCase";
|
||||
import { EventGroupBuilder } from "./usecases/EventGroupBuilder";
|
||||
import { ProfilePermissionProcessor } from "./usecases/ProfilePermissionProcessor";
|
||||
import { EventTypeGroupFilter } from "./utils/EventTypeGroupFilter";
|
||||
|
||||
type GetByViewerOptions = {
|
||||
ctx: {
|
||||
@@ -29,174 +25,45 @@ export const getUserEventGroups = async ({ ctx, input }: GetByViewerOptions) =>
|
||||
identifier: `eventTypes:getUserProfiles:${ctx.user.id}`,
|
||||
rateLimitingType: "common",
|
||||
});
|
||||
|
||||
const user = ctx.user;
|
||||
const filters = input?.filters;
|
||||
const forRoutingForms = input?.forRoutingForms;
|
||||
|
||||
const userProfile = user.profile;
|
||||
|
||||
// Validate profile exists
|
||||
const profile = await ProfileRepository.findByUpId(userProfile.upId);
|
||||
const parentOrgHasLockedEventTypes =
|
||||
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
|
||||
const isFilterSet = filters && hasFilter(filters);
|
||||
const isUpIdInFilter = filters?.upIds?.includes(userProfile.upId);
|
||||
|
||||
let shouldListUserEvents = !isFilterSet || isUpIdInFilter;
|
||||
|
||||
if (isFilterSet && filters?.upIds && !isUpIdInFilter) {
|
||||
shouldListUserEvents = true;
|
||||
}
|
||||
|
||||
const profileMemberships = await MembershipRepository.findAllByUpIdIncludeTeam(
|
||||
{
|
||||
upId: userProfile.upId,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!profile) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
|
||||
const memberships = profileMemberships.map((membership) => ({
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
}));
|
||||
|
||||
const teamMemberships = profileMemberships.map((membership) => ({
|
||||
teamId: membership.team.id,
|
||||
membershipRole: membership.role,
|
||||
}));
|
||||
|
||||
type EventTypeGroup = {
|
||||
teamId?: number | null;
|
||||
parentId?: number | null;
|
||||
bookerUrl: string;
|
||||
membershipRole?: MembershipRole | null;
|
||||
profile: {
|
||||
slug: (typeof profile)["username"] | null;
|
||||
name: (typeof profile)["name"];
|
||||
image: string;
|
||||
eventTypesLockedByOrg?: boolean;
|
||||
};
|
||||
metadata: {
|
||||
membershipCount: number;
|
||||
readOnly: boolean;
|
||||
};
|
||||
// Initialize dependencies
|
||||
const dependencies = {
|
||||
membershipRepository: MembershipRepository,
|
||||
profileRepository: ProfileRepository,
|
||||
teamAccessUseCase: new TeamAccessUseCase(),
|
||||
};
|
||||
|
||||
let eventTypeGroups: EventTypeGroup[] = [];
|
||||
// Build event groups
|
||||
const eventGroupBuilder = new EventGroupBuilder(dependencies);
|
||||
const { eventTypeGroups, teamPermissionsMap } = await eventGroupBuilder.buildEventGroups({
|
||||
userId: user.id,
|
||||
userUpId: userProfile.upId,
|
||||
filters: input?.filters,
|
||||
forRoutingForms: input?.forRoutingForms,
|
||||
});
|
||||
|
||||
if (shouldListUserEvents) {
|
||||
const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null);
|
||||
eventTypeGroups.push({
|
||||
teamId: null,
|
||||
bookerUrl,
|
||||
membershipRole: null,
|
||||
profile: {
|
||||
slug: profile.username,
|
||||
name: profile.name,
|
||||
image: getUserAvatarUrl({
|
||||
avatarUrl: profile.avatarUrl,
|
||||
}),
|
||||
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 1,
|
||||
readOnly: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
const filteredEventTypeGroups = new EventTypeGroupFilter(eventTypeGroups, teamPermissionsMap)
|
||||
.has("eventType.read")
|
||||
.get();
|
||||
|
||||
eventTypeGroups = ([] as EventTypeGroup[]).concat(
|
||||
eventTypeGroups,
|
||||
await Promise.all(
|
||||
memberships
|
||||
.filter((mmship) => {
|
||||
if (mmship.team.isOrganization) {
|
||||
return false;
|
||||
} else {
|
||||
if (!filters || !hasFilter(filters)) {
|
||||
return true;
|
||||
}
|
||||
return filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false;
|
||||
}
|
||||
})
|
||||
.map(async (membership) => {
|
||||
const orgMembership = teamMemberships.find(
|
||||
(teamM) => teamM.teamId === membership.team.parentId
|
||||
)?.membershipRole;
|
||||
// Process profiles with permissions
|
||||
const profileProcessor = new ProfilePermissionProcessor();
|
||||
const profiles = profileProcessor.processProfiles(eventTypeGroups, teamPermissionsMap);
|
||||
|
||||
const team = {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
};
|
||||
|
||||
let slug;
|
||||
|
||||
if (forRoutingForms) {
|
||||
// For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team
|
||||
// Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug
|
||||
slug = `team/${team.slug}`;
|
||||
} else {
|
||||
// In an Org, a team can be accessed without /team prefix as well as with /team prefix
|
||||
slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null;
|
||||
}
|
||||
|
||||
// const eventTypes = await Promise.all(team.eventTypes.map(mapEventType));
|
||||
const teamParentMetadata = team.parent ? teamMetadataSchema.parse(team.parent.metadata) : null;
|
||||
return {
|
||||
teamId: team.id,
|
||||
parentId: team.parentId,
|
||||
bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? teamParentMetadata?.requestedSlug ?? null),
|
||||
membershipRole:
|
||||
orgMembership && compareMembership(orgMembership, membership.role)
|
||||
? orgMembership
|
||||
: membership.role,
|
||||
profile: {
|
||||
image: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
name: team.name,
|
||||
slug,
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 0,
|
||||
readOnly:
|
||||
membership.role ===
|
||||
(team.parentId
|
||||
? orgMembership && compareMembership(orgMembership, membership.role)
|
||||
? orgMembership
|
||||
: MembershipRole.MEMBER
|
||||
: MembershipRole.MEMBER),
|
||||
},
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const denormalizedPayload = {
|
||||
eventTypeGroups,
|
||||
// so we can show a dropdown when the user has teams
|
||||
profiles: eventTypeGroups.map((group) => ({
|
||||
...group.profile,
|
||||
...group.metadata,
|
||||
teamId: group.teamId,
|
||||
membershipRole: group.membershipRole,
|
||||
})),
|
||||
return {
|
||||
eventTypeGroups: filteredEventTypeGroups,
|
||||
profiles,
|
||||
};
|
||||
|
||||
return denormalizedPayload;
|
||||
};
|
||||
|
||||
export function compareMembership(mship1: MembershipRole, mship2: MembershipRole) {
|
||||
const mshipToNumber = (mship: MembershipRole) =>
|
||||
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
|
||||
return mshipToNumber(mship1) > mshipToNumber(mship2);
|
||||
}
|
||||
// Re-export the compareMembership function for backward compatibility
|
||||
export { compareMembership } from "@calcom/lib/event-types/getEventTypesByViewer";
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { logP } from "@calcom/lib/perf";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import authedProcedure from "../../../../procedures/authedProcedure";
|
||||
import { router } from "../../../../trpc";
|
||||
import { createEventPbacProcedure } from "../util";
|
||||
import { ZCreateInputSchema } from "./create.schema";
|
||||
import { ZDuplicateInputSchema } from "./duplicate.schema";
|
||||
import { eventOwnerProcedure } from "../util";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
|
||||
type BookingsRouterHandlerCache = {
|
||||
create?: typeof import("./create.handler").createHandler;
|
||||
duplicate?: typeof import("./duplicate.handler").duplicateHandler;
|
||||
update?: typeof import("./update.handler").updateHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
|
||||
|
||||
export const eventTypesRouter = router({
|
||||
create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { createHandler } = await import("./create.handler");
|
||||
@@ -26,20 +16,24 @@ export const eventTypesRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
duplicate: eventOwnerProcedure.input(ZDuplicateInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { duplicateHandler } = await import("./duplicate.handler");
|
||||
duplicate: createEventPbacProcedure("eventType.create", [MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
.input(ZDuplicateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { duplicateHandler } = await import("./duplicate.handler");
|
||||
|
||||
return duplicateHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
update: eventOwnerProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { updateHandler } = await import("./update.handler");
|
||||
return duplicateHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
update: createEventPbacProcedure("eventType.update", [MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
.input(ZUpdateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { updateHandler } = await import("./update.handler");
|
||||
|
||||
return updateHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
})
|
||||
return updateHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { z } from "zod";
|
||||
|
||||
import { getDefaultLocations } from "@calcom/app-store/_utils/getDefaultLocations";
|
||||
import { DailyLocationType } from "@calcom/app-store/constants";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { eventTypeLocations } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -53,6 +55,29 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
|
||||
const isOrgAdmin = !!ctx.user?.organization?.isOrgAdmin;
|
||||
|
||||
// Check if user has organization-level eventType.create permission (equivalent to org admin for event types)
|
||||
let hasOrgEventTypeCreatePermission = isOrgAdmin; // Default fallback
|
||||
|
||||
if (ctx.user.organizationId) {
|
||||
try {
|
||||
const orgPermissions = await getResourcePermissions({
|
||||
userId,
|
||||
teamId: ctx.user.organizationId,
|
||||
resource: Resource.EventType,
|
||||
userRole: isOrgAdmin ? MembershipRole.ADMIN : MembershipRole.MEMBER,
|
||||
fallbackRoles: {
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
hasOrgEventTypeCreatePermission = orgPermissions.canCreate;
|
||||
} catch (error) {
|
||||
// If PBAC check fails, fall back to isOrgAdmin
|
||||
hasOrgEventTypeCreatePermission = isOrgAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
const locations: EventTypeLocation[] =
|
||||
inputLocations && inputLocations.length !== 0 ? inputLocations : await getDefaultLocations(ctx.user);
|
||||
|
||||
@@ -91,14 +116,41 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasMembership) {
|
||||
console.warn(`User ${userId} is not a member of team ${teamId}`);
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const isSystemAdmin = ctx.user.role === "ADMIN";
|
||||
|
||||
if (
|
||||
!isSystemAdmin &&
|
||||
!isOrgAdmin &&
|
||||
(!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role))
|
||||
) {
|
||||
console.warn(`User ${userId} does not have permission to create this new event type`);
|
||||
// Check PBAC permissions for eventType.create with fallback to role-based check
|
||||
let hasCreatePermission = false;
|
||||
|
||||
try {
|
||||
const permissions = await getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.EventType,
|
||||
userRole: hasMembership.role,
|
||||
fallbackRoles: {
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
hasCreatePermission = permissions.canCreate;
|
||||
} catch (error) {
|
||||
// If PBAC check fails, fall back to role-based check
|
||||
console.warn(
|
||||
`PBAC check failed for user ${userId} on team ${teamId}, falling back to role check:`,
|
||||
error
|
||||
);
|
||||
hasCreatePermission = ["ADMIN", "OWNER"].includes(hasMembership.role);
|
||||
}
|
||||
|
||||
// System admins and users with org-level eventType.create permission can always create event types
|
||||
if (!isSystemAdmin && !hasOrgEventTypeCreatePermission && !hasCreatePermission) {
|
||||
console.warn(`User ${userId} does not have eventType.create permission for team ${teamId}`);
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
@@ -110,9 +162,9 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
data.schedulingType = schedulingType;
|
||||
}
|
||||
|
||||
// If we are in an organization & they are not admin & they are not creating an event on a teamID
|
||||
// If we are in an organization & they don't have org-level eventType.create permission & they are not creating an event on a teamID
|
||||
// Check if evenTypes are locked.
|
||||
if (ctx.user.organizationId && !ctx.user?.organization?.isOrgAdmin && !teamId) {
|
||||
if (ctx.user.organizationId && !hasOrgEventTypeCreatePermission && !teamId) {
|
||||
const orgSettings = await ctx.prisma.organizationSettings.findUnique({
|
||||
where: {
|
||||
organizationId: ctx.user.organizationId,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TeamAccessUseCase } from "./teamAccessUseCase";
|
||||
|
||||
// Mock the PermissionCheckService
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
|
||||
PermissionCheckService: vi.fn().mockImplementation(() => ({
|
||||
checkPermission: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("TeamAccessUseCase", () => {
|
||||
let teamAccessUseCase: TeamAccessUseCase;
|
||||
let mockCheckPermission: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
teamAccessUseCase = new TeamAccessUseCase();
|
||||
mockCheckPermission = (teamAccessUseCase as any).permissionCheckService.checkPermission;
|
||||
});
|
||||
|
||||
describe("filterTeamsByEventTypeReadPermission", () => {
|
||||
const createMockMembership = (teamId: number, isOrganization = false) => ({
|
||||
id: teamId * 100,
|
||||
teamId,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
customRoleId: null,
|
||||
team: {
|
||||
id: teamId,
|
||||
name: `Team ${teamId}`,
|
||||
slug: `team-${teamId}`,
|
||||
logoUrl: null,
|
||||
isOrganization,
|
||||
parentId: isOrganization ? null : 1,
|
||||
metadata: {},
|
||||
theme: null,
|
||||
brandColor: null,
|
||||
darkBrandColor: null,
|
||||
bio: null,
|
||||
hideBranding: false,
|
||||
hideBookATeamMember: false,
|
||||
isPrivate: false,
|
||||
parent: null,
|
||||
calVideoLogo: null,
|
||||
appLogo: null,
|
||||
appIconLogo: null,
|
||||
bannerUrl: null,
|
||||
timeFormat: null,
|
||||
timeZone: "UTC",
|
||||
weekStart: "Monday",
|
||||
createdAt: new Date(),
|
||||
pendingPayment: false,
|
||||
isPlatform: false,
|
||||
smsLockState: "UNLOCKED" as const,
|
||||
smsLockReviewedByAdmin: false,
|
||||
includeManagedEventsInLimits: false,
|
||||
bookingLimits: null,
|
||||
rrResetInterval: "MONTH" as const,
|
||||
rrTimestampBasis: "CREATED_AT" as const,
|
||||
createdByOAuthClientId: null,
|
||||
hideTeamProfileLink: false,
|
||||
},
|
||||
});
|
||||
|
||||
it("should filter out organization memberships", async () => {
|
||||
const memberships = [
|
||||
createMockMembership(1, false),
|
||||
createMockMembership(2, true), // Organization
|
||||
createMockMembership(3, false),
|
||||
];
|
||||
|
||||
mockCheckPermission.mockResolvedValue(true);
|
||||
|
||||
const result = await teamAccessUseCase.filterTeamsByEventTypeReadPermission(memberships as any, 1);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.teamId)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("should filter out teams without eventType.read permission when PBAC is enabled", async () => {
|
||||
const memberships = [
|
||||
createMockMembership(1, false),
|
||||
createMockMembership(2, false),
|
||||
createMockMembership(3, false),
|
||||
];
|
||||
|
||||
mockCheckPermission
|
||||
.mockResolvedValueOnce(true) // Team 1 has permission
|
||||
.mockResolvedValueOnce(false) // Team 2 does not have permission
|
||||
.mockResolvedValueOnce(true); // Team 3 has permission
|
||||
|
||||
const result = await teamAccessUseCase.filterTeamsByEventTypeReadPermission(memberships as any, 1);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((m) => m.teamId)).toEqual([1, 3]);
|
||||
expect(mockCheckPermission).toHaveBeenCalledTimes(3);
|
||||
expect(mockCheckPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
permission: "eventType.read",
|
||||
fallbackRoles: ["ADMIN", "OWNER", "MEMBER"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return all non-organization teams when PBAC returns true for all", async () => {
|
||||
const memberships = [createMockMembership(1, false), createMockMembership(2, false)];
|
||||
|
||||
mockCheckPermission.mockResolvedValue(true);
|
||||
|
||||
const result = await teamAccessUseCase.filterTeamsByEventTypeReadPermission(memberships as any, 1);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(memberships);
|
||||
});
|
||||
|
||||
it("should return empty array when no teams have permission", async () => {
|
||||
const memberships = [createMockMembership(1, false), createMockMembership(2, false)];
|
||||
|
||||
mockCheckPermission.mockResolvedValue(false);
|
||||
|
||||
const result = await teamAccessUseCase.filterTeamsByEventTypeReadPermission(memberships as any, 1);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { Membership, Team } from "@calcom/prisma/client";
|
||||
|
||||
type TeamMembershipWithTeam = Membership & {
|
||||
team: Team & {
|
||||
parent?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
logoUrl: string | null;
|
||||
parentId: number | null;
|
||||
metadata: any;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
export class TeamAccessUseCase {
|
||||
constructor(private permissionCheckService: PermissionCheckService = new PermissionCheckService()) {}
|
||||
|
||||
async filterTeamsByEventTypeReadPermission(
|
||||
memberships: TeamMembershipWithTeam[],
|
||||
userId: number
|
||||
): Promise<TeamMembershipWithTeam[]> {
|
||||
const filteredMemberships = await Promise.all(
|
||||
memberships.map(async (membership) => {
|
||||
// Organization memberships are excluded from this logic
|
||||
if (membership.team.isOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user has eventType.read permission for this team
|
||||
const hasPermission = await this.permissionCheckService.checkPermission({
|
||||
userId,
|
||||
teamId: membership.team.id,
|
||||
permission: "eventType.read",
|
||||
fallbackRoles: ["ADMIN", "OWNER", "MEMBER"], // All roles can read event types by default
|
||||
});
|
||||
|
||||
return hasPermission ? membership : null;
|
||||
})
|
||||
);
|
||||
|
||||
return filteredMemberships.filter(
|
||||
(membership): membership is TeamMembershipWithTeam => membership !== null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import type { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { TeamAccessUseCase } from "../teamAccessUseCase";
|
||||
import {
|
||||
shouldListUserEvents,
|
||||
shouldIncludeTeamMembership,
|
||||
createTeamSlug,
|
||||
type FilterContext,
|
||||
} from "../utils/filterUtils";
|
||||
import { buildTeamPermissionsMap, getEffectiveRole, type TeamPermissions } from "../utils/permissionUtils";
|
||||
import { createUserEventGroup, createTeamEventGroup, type EventTypeGroup } from "../utils/transformUtils";
|
||||
|
||||
export interface EventGroupBuilderDependencies {
|
||||
membershipRepository: typeof MembershipRepository;
|
||||
profileRepository: typeof ProfileRepository;
|
||||
teamAccessUseCase: TeamAccessUseCase;
|
||||
}
|
||||
|
||||
export interface EventGroupBuilderInput {
|
||||
userId: number;
|
||||
userUpId: string;
|
||||
filters?: FilterContext["filters"];
|
||||
forRoutingForms?: boolean;
|
||||
}
|
||||
|
||||
export class EventGroupBuilder {
|
||||
constructor(private dependencies: EventGroupBuilderDependencies) {}
|
||||
|
||||
async buildEventGroups(input: EventGroupBuilderInput): Promise<{
|
||||
eventTypeGroups: EventTypeGroup[];
|
||||
teamPermissionsMap: Map<number, TeamPermissions>;
|
||||
}> {
|
||||
const { userId, userUpId, filters, forRoutingForms } = input;
|
||||
|
||||
// Get user profile
|
||||
const profile = await this.dependencies.profileRepository.findByUpId(userUpId);
|
||||
if (!profile) {
|
||||
throw new Error("Profile not found");
|
||||
}
|
||||
|
||||
const parentOrgHasLockedEventTypes =
|
||||
profile.organization?.organizationSettings?.lockEventTypeCreationForUsers;
|
||||
|
||||
// Get memberships
|
||||
const profileMemberships = await this.dependencies.membershipRepository.findAllByUpIdIncludeTeam(
|
||||
{ upId: userUpId },
|
||||
{ where: { accepted: true } }
|
||||
);
|
||||
|
||||
// Filter memberships based on PBAC permissions
|
||||
const accessibleMemberships =
|
||||
await this.dependencies.teamAccessUseCase.filterTeamsByEventTypeReadPermission(
|
||||
profileMemberships,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!accessibleMemberships) {
|
||||
throw new Error("Failed to filter team memberships");
|
||||
}
|
||||
|
||||
const memberships = accessibleMemberships.map((membership) => ({
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
}));
|
||||
|
||||
const teamMemberships = accessibleMemberships.map((membership) => ({
|
||||
teamId: membership.team.id,
|
||||
membershipRole: membership.role,
|
||||
}));
|
||||
|
||||
// Build permissions map
|
||||
const teamPermissionsMap = await buildTeamPermissionsMap(memberships, teamMemberships, userId);
|
||||
|
||||
// Build event type groups
|
||||
const eventTypeGroups: EventTypeGroup[] = [];
|
||||
|
||||
// Add user events if needed
|
||||
const filterContext: FilterContext = { filters, userUpId };
|
||||
if (shouldListUserEvents(filterContext)) {
|
||||
const userGroup = await createUserEventGroup(profile, parentOrgHasLockedEventTypes ?? false);
|
||||
eventTypeGroups.push(userGroup);
|
||||
}
|
||||
|
||||
// Add team events
|
||||
const teamGroupResults = await Promise.allSettled(
|
||||
memberships
|
||||
.filter((membership) => shouldIncludeTeamMembership(membership, filters))
|
||||
.map(async (membership) => {
|
||||
const orgMembership = teamMemberships.find(
|
||||
(teamM) => teamM.teamId === membership.team.parentId
|
||||
)?.membershipRole;
|
||||
|
||||
const effectiveRole = getEffectiveRole(orgMembership, membership.role);
|
||||
const permissions = teamPermissionsMap.get(membership.team.id);
|
||||
|
||||
if (!permissions) {
|
||||
throw new Error(`Permissions not found for team ${membership.team.id}`);
|
||||
}
|
||||
|
||||
const teamSlug = createTeamSlug(
|
||||
membership.team.slug,
|
||||
!!membership.team.parentId,
|
||||
forRoutingForms ?? false
|
||||
);
|
||||
|
||||
return createTeamEventGroup(membership, effectiveRole, teamSlug, permissions);
|
||||
})
|
||||
);
|
||||
|
||||
const teamGroups = teamGroupResults
|
||||
.filter((result): result is PromiseFulfilledResult<EventTypeGroup> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
const failedTeams = teamGroupResults.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected"
|
||||
);
|
||||
|
||||
if (failedTeams.length > 0) {
|
||||
console.warn(`Failed to process ${failedTeams.length} teams:`, failedTeams);
|
||||
}
|
||||
|
||||
eventTypeGroups.push(...teamGroups);
|
||||
|
||||
return {
|
||||
eventTypeGroups,
|
||||
teamPermissionsMap,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { TeamPermissions } from "../utils/permissionUtils";
|
||||
import {
|
||||
createProfilesWithPermissions,
|
||||
type EventTypeGroup,
|
||||
type ProfileWithPermissions,
|
||||
} from "../utils/transformUtils";
|
||||
|
||||
export class ProfilePermissionProcessor {
|
||||
processProfiles(
|
||||
eventTypeGroups: EventTypeGroup[],
|
||||
teamPermissionsMap: Map<number, TeamPermissions>
|
||||
): ProfileWithPermissions[] {
|
||||
return createProfilesWithPermissions(eventTypeGroups, teamPermissionsMap);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -59,13 +61,7 @@ export const eventOwnerProcedure = authedProcedure
|
||||
|
||||
const isAuthorized = (function () {
|
||||
if (event.team) {
|
||||
const isOrgAdmin = !!ctx.user?.organization?.isOrgAdmin;
|
||||
return (
|
||||
event.team.members
|
||||
.filter((member) => checkAdminOrOwner(member.role))
|
||||
.map((member) => member.userId)
|
||||
.includes(ctx.user.id) || isOrgAdmin
|
||||
);
|
||||
return event.team.members.map((member) => member.userId).includes(ctx.user.id);
|
||||
}
|
||||
return event.userId === ctx.user.id || event.users.find((user) => user.id === ctx.user.id);
|
||||
})();
|
||||
@@ -92,6 +88,62 @@ export const eventOwnerProcedure = authedProcedure
|
||||
return next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates an event admin procedure with configurable permissions
|
||||
* @param permission - The specific permission required (e.g., "eventType.manage", "eventType.update")
|
||||
* @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"])
|
||||
* @returns A procedure that checks the specified permission
|
||||
*/
|
||||
export const createEventPbacProcedure = (
|
||||
permission: PermissionString,
|
||||
fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"]
|
||||
) => {
|
||||
return eventOwnerProcedure.use(async ({ ctx, input, next }) => {
|
||||
const id = input.eventTypeId ?? input.id;
|
||||
|
||||
const event = await ctx.prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
// For team events, check PBAC permissions
|
||||
if (event.teamId) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: event.teamId,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For personal events, only the owner can manage
|
||||
if (event.userId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
export function isPeriodType(keyInput: string): keyInput is PeriodType {
|
||||
return Object.keys(PeriodType).includes(keyInput);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { filterEvents } from "./EventTypeGroupFilter";
|
||||
import type { TeamPermissions } from "./permissionUtils";
|
||||
import type { EventTypeGroup } from "./transformUtils";
|
||||
|
||||
describe("EventTypeGroupFilter", () => {
|
||||
const mockUserGroup: EventTypeGroup = {
|
||||
teamId: null,
|
||||
bookerUrl: "https://cal.com/user",
|
||||
membershipRole: null,
|
||||
profile: {
|
||||
slug: "user",
|
||||
name: "User",
|
||||
image: "avatar.jpg",
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 1,
|
||||
readOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockTeamGroup1: EventTypeGroup = {
|
||||
teamId: 1,
|
||||
bookerUrl: "https://cal.com/team1",
|
||||
membershipRole: MembershipRole.ADMIN,
|
||||
profile: {
|
||||
slug: "team1",
|
||||
name: "Team 1",
|
||||
image: "team1.jpg",
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 5,
|
||||
readOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockTeamGroup2: EventTypeGroup = {
|
||||
teamId: 2,
|
||||
bookerUrl: "https://cal.com/team2",
|
||||
membershipRole: MembershipRole.MEMBER,
|
||||
profile: {
|
||||
slug: "team2",
|
||||
name: "Team 2",
|
||||
image: "team2.jpg",
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 3,
|
||||
readOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockPermissionsMap = new Map<number, TeamPermissions>([
|
||||
[1, { canCreate: true, canEdit: true, canDelete: true, canRead: true }],
|
||||
[2, { canCreate: false, canEdit: true, canDelete: false, canRead: true }],
|
||||
]);
|
||||
|
||||
const allGroups = [mockUserGroup, mockTeamGroup1, mockTeamGroup2];
|
||||
|
||||
describe("has() method", () => {
|
||||
it("should filter groups with eventType.create permission", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).has("eventType.create").get();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(mockUserGroup); // User groups always have permissions
|
||||
expect(result).toContain(mockTeamGroup1); // Team 1 has create permission
|
||||
expect(result).not.toContain(mockTeamGroup2); // Team 2 doesn't have create permission
|
||||
});
|
||||
|
||||
it("should filter groups with eventType.read permission", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).has("eventType.read").get();
|
||||
|
||||
expect(result).toHaveLength(3); // All groups can read
|
||||
expect(result).toContain(mockUserGroup);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
expect(result).toContain(mockTeamGroup2);
|
||||
});
|
||||
|
||||
it("should filter groups with eventType.update permission", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).has("eventType.update").get();
|
||||
|
||||
expect(result).toHaveLength(3); // All groups can edit in this test
|
||||
expect(result).toContain(mockUserGroup);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
expect(result).toContain(mockTeamGroup2);
|
||||
});
|
||||
|
||||
it("should filter groups with eventType.delete permission", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).has("eventType.delete").get();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(mockUserGroup);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
expect(result).not.toContain(mockTeamGroup2);
|
||||
});
|
||||
|
||||
it("should return empty array for unknown permission", () => {
|
||||
const teamOnlyGroups = [mockTeamGroup1, mockTeamGroup2];
|
||||
const result = filterEvents(teamOnlyGroups, mockPermissionsMap)
|
||||
.has("unknown.permission" as "eventType.read")
|
||||
.get();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byTeam() method", () => {
|
||||
it("should filter groups by team ID", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).byTeam(1).get();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
});
|
||||
|
||||
it("should return empty array for non-existent team", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).byTeam(999).get();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userOnly() method", () => {
|
||||
it("should return only user groups", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).userOnly().get();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toContain(mockUserGroup);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamsOnly() method", () => {
|
||||
it("should return only team groups", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).teamsOnly().get();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
expect(result).toContain(mockTeamGroup2);
|
||||
expect(result).not.toContain(mockUserGroup);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readOnly() method", () => {
|
||||
it("should filter by read-only status", () => {
|
||||
const readOnlyResult = filterEvents(allGroups, mockPermissionsMap).readOnly(true).get();
|
||||
const writableResult = filterEvents(allGroups, mockPermissionsMap).readOnly(false).get();
|
||||
|
||||
expect(readOnlyResult).toHaveLength(1);
|
||||
expect(readOnlyResult).toContain(mockTeamGroup2);
|
||||
|
||||
expect(writableResult).toHaveLength(2);
|
||||
expect(writableResult).toContain(mockUserGroup);
|
||||
expect(writableResult).toContain(mockTeamGroup1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chaining methods", () => {
|
||||
it("should support method chaining", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap).teamsOnly().has("eventType.create").get();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
});
|
||||
|
||||
it("should support complex chaining", () => {
|
||||
const result = filterEvents(allGroups, mockPermissionsMap)
|
||||
.has("eventType.update")
|
||||
.readOnly(false)
|
||||
.get();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(mockUserGroup);
|
||||
expect(result).toContain(mockTeamGroup1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("utility methods", () => {
|
||||
it("should return correct count", () => {
|
||||
const count = filterEvents(allGroups, mockPermissionsMap).teamsOnly().count();
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should check existence correctly", () => {
|
||||
const exists = filterEvents(allGroups, mockPermissionsMap).byTeam(1).exists();
|
||||
const notExists = filterEvents(allGroups, mockPermissionsMap).byTeam(999).exists();
|
||||
|
||||
expect(exists).toBe(true);
|
||||
expect(notExists).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
|
||||
import type { TeamPermissions } from "./permissionUtils";
|
||||
import type { EventTypeGroup } from "./transformUtils";
|
||||
|
||||
export class EventTypeGroupFilter {
|
||||
private groups: EventTypeGroup[];
|
||||
private permissionsMap: Map<number, TeamPermissions>;
|
||||
|
||||
constructor(groups: EventTypeGroup[], permissionsMap: Map<number, TeamPermissions>) {
|
||||
this.groups = groups;
|
||||
this.permissionsMap = permissionsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter event type groups by permission
|
||||
* @param permission - Permission to check (e.g., "eventType.read", "eventType.create", "eventType.update", "eventType.delete")
|
||||
* @returns New EventTypeGroupFilter instance with filtered groups
|
||||
*/
|
||||
has(permission: PermissionString): EventTypeGroupFilter {
|
||||
const filteredGroups = this.groups.filter((group) => {
|
||||
// For user groups (no teamId), they have all permissions
|
||||
if (!group.teamId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissions = this.permissionsMap.get(group.teamId);
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (permission) {
|
||||
case "eventType.read":
|
||||
// All groups with permissions can read
|
||||
return permissions.canRead;
|
||||
case "eventType.create":
|
||||
return permissions.canCreate;
|
||||
case "eventType.update":
|
||||
return permissions.canEdit;
|
||||
case "eventType.delete":
|
||||
return permissions.canDelete;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return new EventTypeGroupFilter(filteredGroups, this.permissionsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by team ID
|
||||
* @param teamId - Team ID to filter by
|
||||
* @returns New EventTypeGroupFilter instance with filtered groups
|
||||
*/
|
||||
byTeam(teamId: number): EventTypeGroupFilter {
|
||||
const filteredGroups = this.groups.filter((group) => group.teamId === teamId);
|
||||
return new EventTypeGroupFilter(filteredGroups, this.permissionsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by user groups only (no team)
|
||||
* @returns New EventTypeGroupFilter instance with user groups only
|
||||
*/
|
||||
userOnly(): EventTypeGroupFilter {
|
||||
const filteredGroups = this.groups.filter((group) => !group.teamId);
|
||||
return new EventTypeGroupFilter(filteredGroups, this.permissionsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by team groups only (exclude user groups)
|
||||
* @returns New EventTypeGroupFilter instance with team groups only
|
||||
*/
|
||||
teamsOnly(): EventTypeGroupFilter {
|
||||
const filteredGroups = this.groups.filter((group) => group.teamId);
|
||||
return new EventTypeGroupFilter(filteredGroups, this.permissionsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by read-only status
|
||||
* @param readOnly - Whether to include only read-only or writable groups
|
||||
* @returns New EventTypeGroupFilter instance with filtered groups
|
||||
*/
|
||||
readOnly(readOnly: boolean): EventTypeGroupFilter {
|
||||
const filteredGroups = this.groups.filter((group) => group.metadata.readOnly === readOnly);
|
||||
return new EventTypeGroupFilter(filteredGroups, this.permissionsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filtered results
|
||||
* @returns Array of filtered EventTypeGroup objects
|
||||
*/
|
||||
get(): EventTypeGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of filtered groups
|
||||
* @returns Number of groups after filtering
|
||||
*/
|
||||
count(): number {
|
||||
return this.groups.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any groups match the current filter
|
||||
* @returns Boolean indicating if any groups exist
|
||||
*/
|
||||
exists(): boolean {
|
||||
return this.groups.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new EventTypeGroupFilter instance
|
||||
* @param groups - Array of EventTypeGroup objects
|
||||
* @param permissionsMap - Map of team permissions
|
||||
* @returns EventTypeGroupFilter instance for chaining
|
||||
*/
|
||||
export function filterEvents(
|
||||
groups: EventTypeGroup[],
|
||||
permissionsMap: Map<number, TeamPermissions>
|
||||
): EventTypeGroupFilter {
|
||||
return new EventTypeGroupFilter(groups, permissionsMap);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
|
||||
|
||||
import type { TEventTypeInputSchema } from "../getByViewer.schema";
|
||||
|
||||
type FiltersType = NonNullable<TEventTypeInputSchema>["filters"];
|
||||
|
||||
export interface FilterContext {
|
||||
filters?: FiltersType;
|
||||
userUpId: string;
|
||||
}
|
||||
|
||||
export function shouldListUserEvents(context: FilterContext): boolean {
|
||||
const { filters, userUpId } = context;
|
||||
const isFilterSet = filters && hasFilter(filters);
|
||||
const isUpIdInFilter = filters?.upIds?.includes(userUpId) ?? false;
|
||||
|
||||
let shouldList = !isFilterSet || isUpIdInFilter;
|
||||
|
||||
if (isFilterSet && filters?.upIds && !isUpIdInFilter) {
|
||||
shouldList = true;
|
||||
}
|
||||
|
||||
return shouldList;
|
||||
}
|
||||
|
||||
export function shouldIncludeTeamMembership(
|
||||
membership: { team: { id: number } },
|
||||
filters?: FiltersType
|
||||
): boolean {
|
||||
if (!filters || !hasFilter(filters)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return filters?.teamIds?.includes(membership.team.id) ?? false;
|
||||
}
|
||||
|
||||
export function createTeamSlug(
|
||||
teamSlug: string | null,
|
||||
hasParent: boolean,
|
||||
forRoutingForms: boolean
|
||||
): string | null {
|
||||
if (!teamSlug) return null;
|
||||
|
||||
if (forRoutingForms) {
|
||||
return `team/${teamSlug}`;
|
||||
}
|
||||
|
||||
return hasParent ? teamSlug : `team/${teamSlug}`;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
export interface TeamPermissions {
|
||||
canCreate: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canRead: boolean;
|
||||
}
|
||||
|
||||
export interface MembershipWithRole {
|
||||
teamId: number;
|
||||
membershipRole: MembershipRole;
|
||||
}
|
||||
|
||||
const MEMBERSHIP_HIERARCHY: Record<MembershipRole, number> = {
|
||||
[MembershipRole.MEMBER]: 1,
|
||||
[MembershipRole.ADMIN]: 2,
|
||||
[MembershipRole.OWNER]: 3,
|
||||
};
|
||||
|
||||
export function hasHigherPrivilege(role1: MembershipRole, role2: MembershipRole): boolean {
|
||||
return MEMBERSHIP_HIERARCHY[role1] > MEMBERSHIP_HIERARCHY[role2];
|
||||
}
|
||||
|
||||
export function getEffectiveRole(
|
||||
orgMembership: MembershipRole | undefined,
|
||||
membershipRole: MembershipRole
|
||||
): MembershipRole {
|
||||
return orgMembership && hasHigherPrivilege(orgMembership, membershipRole) ? orgMembership : membershipRole;
|
||||
}
|
||||
|
||||
export async function getTeamPermissions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
effectiveRole: MembershipRole
|
||||
): Promise<TeamPermissions> {
|
||||
try {
|
||||
const permissions = await getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.EventType,
|
||||
userRole: effectiveRole,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
canCreate: permissions.canCreate,
|
||||
canEdit: permissions.canEdit,
|
||||
canDelete: permissions.canDelete,
|
||||
canRead: permissions.canRead,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`PBAC check failed for user ${userId} on team ${teamId}, falling back to role check:`,
|
||||
error
|
||||
);
|
||||
|
||||
return getFallbackPermissions(effectiveRole);
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackPermissions(role: MembershipRole): TeamPermissions {
|
||||
const isAdminOrOwner = role === MembershipRole.ADMIN || role === MembershipRole.OWNER;
|
||||
const isMember = role === MembershipRole.MEMBER;
|
||||
|
||||
return {
|
||||
canRead: isAdminOrOwner || isMember,
|
||||
canCreate: isAdminOrOwner,
|
||||
canEdit: isAdminOrOwner,
|
||||
canDelete: isAdminOrOwner,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildTeamPermissionsMap(
|
||||
memberships: Array<{ team: { id: number; parentId?: number | null }; role: MembershipRole }>,
|
||||
teamMemberships: MembershipWithRole[],
|
||||
userId: number
|
||||
): Promise<Map<number, TeamPermissions>> {
|
||||
const permissionPromises = memberships.map(async (membership) => {
|
||||
const orgMembership = teamMemberships.find(
|
||||
(teamM) => teamM.teamId === membership.team.parentId
|
||||
)?.membershipRole;
|
||||
|
||||
const effectiveRole = getEffectiveRole(orgMembership, membership.role);
|
||||
const permissions = await getTeamPermissions(userId, membership.team.id, effectiveRole);
|
||||
|
||||
return [membership.team.id, permissions] as const;
|
||||
});
|
||||
|
||||
const results = await Promise.all(permissionPromises);
|
||||
return new Map(results);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { TeamPermissions } from "./permissionUtils";
|
||||
|
||||
export interface EventTypeGroup {
|
||||
teamId?: number | null;
|
||||
parentId?: number | null;
|
||||
bookerUrl: string;
|
||||
membershipRole?: MembershipRole | null;
|
||||
profile: {
|
||||
slug: string | null;
|
||||
name: string | null;
|
||||
image: string;
|
||||
eventTypesLockedByOrg?: boolean;
|
||||
};
|
||||
metadata: {
|
||||
membershipCount: number;
|
||||
readOnly: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileWithPermissions {
|
||||
slug: string | null;
|
||||
name: string | null;
|
||||
image: string;
|
||||
eventTypesLockedByOrg?: boolean;
|
||||
membershipCount: number;
|
||||
readOnly: boolean;
|
||||
teamId?: number | null;
|
||||
membershipRole?: MembershipRole | null;
|
||||
canCreateEventTypes?: boolean;
|
||||
canUpdateEventTypes?: boolean;
|
||||
canDeleteEventTypes?: boolean;
|
||||
}
|
||||
|
||||
export async function createUserEventGroup(
|
||||
profile: {
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
organizationId: number | null;
|
||||
},
|
||||
parentOrgHasLockedEventTypes: boolean
|
||||
): Promise<EventTypeGroup> {
|
||||
const bookerUrl = await getBookerBaseUrl(profile.organizationId);
|
||||
|
||||
return {
|
||||
teamId: null,
|
||||
bookerUrl,
|
||||
membershipRole: null,
|
||||
profile: {
|
||||
slug: profile.username,
|
||||
name: profile.name,
|
||||
image: getUserAvatarUrl({
|
||||
avatarUrl: profile.avatarUrl,
|
||||
}),
|
||||
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 1,
|
||||
readOnly: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTeamEventGroup(
|
||||
membership: {
|
||||
team: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
logoUrl: string | null;
|
||||
parentId: number | null;
|
||||
parent?: {
|
||||
name: string;
|
||||
slug: string | null;
|
||||
logoUrl: string | null;
|
||||
metadata: unknown;
|
||||
} | null;
|
||||
metadata: unknown;
|
||||
};
|
||||
role: MembershipRole;
|
||||
},
|
||||
effectiveRole: MembershipRole,
|
||||
teamSlug: string | null,
|
||||
permissions: TeamPermissions
|
||||
): Promise<EventTypeGroup> {
|
||||
const team = {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
};
|
||||
|
||||
const teamParentMetadata = team.parent ? teamMetadataSchema.parse(team.parent.metadata) : null;
|
||||
|
||||
return {
|
||||
teamId: team.id,
|
||||
parentId: team.parentId,
|
||||
bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? teamParentMetadata?.requestedSlug ?? null),
|
||||
membershipRole: effectiveRole,
|
||||
profile: {
|
||||
image: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
name: team.name,
|
||||
slug: teamSlug,
|
||||
},
|
||||
metadata: {
|
||||
membershipCount: 0,
|
||||
readOnly: !permissions.canEdit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createProfilesWithPermissions(
|
||||
eventTypeGroups: EventTypeGroup[],
|
||||
teamPermissionsMap: Map<number, TeamPermissions>
|
||||
): ProfileWithPermissions[] {
|
||||
return eventTypeGroups.map((group) => {
|
||||
let canCreateEventTypes: boolean | undefined = undefined;
|
||||
let canUpdateEventTypes: boolean | undefined = undefined;
|
||||
let canDeleteEventTypes: boolean | undefined = undefined;
|
||||
|
||||
if (group.teamId) {
|
||||
const permissions = teamPermissionsMap.get(group.teamId);
|
||||
canCreateEventTypes = permissions?.canCreate;
|
||||
canUpdateEventTypes = permissions?.canEdit;
|
||||
canDeleteEventTypes = permissions?.canDelete;
|
||||
}
|
||||
|
||||
return {
|
||||
...group.profile,
|
||||
...group.metadata,
|
||||
teamId: group.teamId,
|
||||
membershipRole: group.membershipRole,
|
||||
canCreateEventTypes,
|
||||
canUpdateEventTypes,
|
||||
canDeleteEventTypes,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { scheduleEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { scheduleSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
import { scheduleWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
@@ -37,9 +38,6 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
NOT: {
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -97,6 +95,21 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
|
||||
if (!eventType)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" });
|
||||
|
||||
if (eventType.teamId) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
const hasPermissionToActivate = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: eventType.teamId,
|
||||
permission: "eventType.update",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasPermissionToActivate) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" });
|
||||
}
|
||||
}
|
||||
|
||||
// at this point we know that the event type belongs to the user or team
|
||||
// so we don't use OR, we use logic.
|
||||
const whereClause = eventType.teamId ? { teamId: eventType.teamId } : { userId: ctx.user.id };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
|
||||
import { isTeamMember } from "@calcom/lib/server/queries/teams";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -25,20 +26,39 @@ export const getAllActiveWorkflowsHandler = async ({ input, ctx }: GetAllActiveW
|
||||
metadata: eventType.metadata,
|
||||
};
|
||||
|
||||
if (eventType.userId && eventType.userId !== ctx.user.id) {
|
||||
if (
|
||||
eventType.userId &&
|
||||
eventType.userId !== ctx.user.id &&
|
||||
!eventType.teamId &&
|
||||
!eventType.parent?.teamId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
if (eventType.teamId) {
|
||||
const team = await isTeamMember(ctx.user?.id, eventType.teamId);
|
||||
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const hasPermissionToViewWorkflows = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: eventType.teamId,
|
||||
permission: "workflow.read",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasPermissionToViewWorkflows) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
if (eventType.parent?.teamId) {
|
||||
const team = await isTeamMember(ctx.user?.id, eventType.parent?.teamId);
|
||||
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const hasPermissionToViewWorkflows = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: eventType.parent?.teamId,
|
||||
permission: "workflow.read",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasPermissionToViewWorkflows) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const allActiveWorkflows = await getAllWorkflowsFromEventType(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
|
||||
// import dayjs from "@calcom/dayjs";
|
||||
// import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { addPermissionsToWorkflows } from "@calcom/lib/server/repository/workflow-permissions";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
@@ -10,6 +12,7 @@ import type { TListInputSchema } from "./list.schema";
|
||||
type ListOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TListInputSchema;
|
||||
};
|
||||
@@ -156,7 +159,13 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
|
||||
|
||||
workflows.push(...workflowsWithReadOnly);
|
||||
|
||||
return { workflows };
|
||||
// Add permissions to each workflow
|
||||
const workflowsWithPermissions = await addPermissionsToWorkflows(workflows, ctx.user.id);
|
||||
|
||||
// Filter workflows based on view permission
|
||||
const filteredWorkflows = workflowsWithPermissions.filter((workflow) => workflow.permissions.canView);
|
||||
|
||||
return { workflows: filteredWorkflows };
|
||||
}
|
||||
|
||||
if (input && input.userId) {
|
||||
@@ -198,7 +207,13 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
|
||||
|
||||
workflows.push(...userWorkflows);
|
||||
|
||||
return { workflows };
|
||||
// Add permissions to each workflow
|
||||
const workflowsWithPermissions = await addPermissionsToWorkflows(workflows, ctx.user.id);
|
||||
|
||||
// Filter workflows based on view permission
|
||||
const filteredWorkflows = workflowsWithPermissions.filter((workflow) => workflow.permissions.canView);
|
||||
|
||||
return { workflows: filteredWorkflows };
|
||||
}
|
||||
|
||||
const allWorkflows = await prisma.workflow.findMany({
|
||||
@@ -259,5 +274,11 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
|
||||
|
||||
workflows.push(...workflowsWithReadOnly);
|
||||
|
||||
return { workflows };
|
||||
// Add permissions to each workflow
|
||||
const workflowsWithPermissions = await addPermissionsToWorkflows(workflows, ctx.user.id);
|
||||
|
||||
// Filter workflows based on view permission
|
||||
const filteredWorkflows = workflowsWithPermissions.filter((workflow) => workflow.permissions.canView);
|
||||
|
||||
return { workflows: filteredWorkflows };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user