diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index eb80f58aea..df52c18e56 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -72,7 +72,13 @@ function useBreakPoints() { * It has the the ongoing changes in the form along with enrichedWithUserProfileForm specific data. * So, it can be used to test the form in the test preview dialog without saving the changes even. */ -function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) { +function SingleForm({ + form, + appUrl, + Page, + enrichedWithUserProfileForm, + permissions, +}: SingleFormComponentProps) { const utils = trpc.useUtils(); const { t } = useLocale(); const [newFormDialogState, setNewFormDialogState] = useState(null); @@ -153,6 +159,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF setShowInfoLostDialog={setShowInfoLostDialog} setIsTestPreviewOpen={setIsTestPreviewOpen} isTestPreviewOpen={isTestPreviewOpen} + permissions={permissions} />
void; @@ -66,6 +67,12 @@ const Actions = ({ appUrl: string; isTestPreviewOpen: boolean; isMobile?: boolean; + permissions: { + canCreate: boolean; + canRead: boolean; + canEdit: boolean; + canDelete: boolean; + }; }) => { const { t } = useLocale(); const formContext = useFormContext(); @@ -165,6 +172,7 @@ const Actions = ({ className="w-full" type="button" color="destructive" + disabled={!permissions.canDelete} StartIcon="trash"> {t("delete")} @@ -182,6 +190,7 @@ const Actions = ({
@@ -357,6 +374,7 @@ export function Header({ isTestPreviewOpen={isTestPreviewOpen} isSaving={isSaving} appUrl={appUrl} + permissions={permissions} /> diff --git a/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts b/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts index 7c8b0cd888..c5c839253a 100644 --- a/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts +++ b/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts @@ -1,3 +1,7 @@ +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps"; import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData"; @@ -30,16 +34,6 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP }; } - const isFormCreateEditAllowed = (await import("../lib/isFormCreateEditAllowed")).isFormCreateEditAllowed; - if (!(await isFormCreateEditAllowed({ userId: user.id, formId, targetTeamId: null }))) { - return { - redirect: { - permanent: false, - destination: "/403", - }, - }; - } - const form = await prisma.app_RoutingForms_Form.findUnique({ where: { id: formId, @@ -105,12 +99,69 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP user: await userRepo.enrichUserWithItsProfile({ user: form.user }), }; + // Get PBAC permissions for team-scoped routing forms + let permissions = { + canCreate: false, + canRead: false, + canEdit: false, + canDelete: false, + }; + + if (!form.teamId) { + // For personal forms (teamId = null), + // check if the form belongs to the current user + if (form.userId !== user.id) { + return { + notFound: true, + }; + } + + permissions = { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }; + } else { + // team-scoped routing form + // Get user's role in the team + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ + userId: user.id, + teamId: form.teamId, + }); + + if (!membership) { + return { + notFound: true, + }; + } + + permissions = await getResourcePermissions({ + userId: user.id, + teamId: form.teamId, + resource: Resource.RoutingForm, + userRole: membership.role, + fallbackRoles: { + read: { roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER] }, + create: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + update: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + delete: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + }); + } + return { props: { form: await getSerializableForm({ form: formWithoutProfileInfo }), enrichedWithUserProfileForm: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserInfoProfile), }), + permissions: { + canCreate: permissions.canCreate, + canRead: permissions.canRead, + canEdit: permissions.canEdit, + canDelete: permissions.canDelete, + }, }, }; }; diff --git a/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx b/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx index e8830639ad..4a48d87be7 100644 --- a/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx @@ -348,6 +348,7 @@ const FormEdit = ({ export default function FormEditPage({ appUrl, + permissions, ...props }: inferSSRProps & { appUrl: string }) { return ( @@ -356,6 +357,7 @@ export default function FormEditPage({ } /> diff --git a/packages/app-store/routing-forms/pages/forms/[...appPages].tsx b/packages/app-store/routing-forms/pages/forms/[...appPages].tsx index 8f586caeb6..64abaf47f4 100644 --- a/packages/app-store/routing-forms/pages/forms/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/forms/[...appPages].tsx @@ -218,7 +218,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) { & { appUrl: string }) { return ( ); diff --git a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx index c5b304b850..7a7b766cc6 100644 --- a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx @@ -266,6 +266,7 @@ const Reporter = ({ form }: { form: inferSSRProps["fo }; export default function ReporterWrapper({ + permissions, ...props }: inferSSRProps & { appUrl: string }) { const [isClient, setIsClient] = useState(false); @@ -280,6 +281,7 @@ export default function ReporterWrapper({ return ( (
{isClient && } diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index 8dfc34538e..032d2b6181 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -1429,6 +1429,7 @@ export default function RouteBuilder({ form, appUrl, enrichedWithUserProfileForm, + permissions, }: inferSSRProps & { appUrl: string }) { return ( <> @@ -1436,6 +1437,7 @@ export default function RouteBuilder({ form={form} appUrl={appUrl} enrichedWithUserProfileForm={enrichedWithUserProfileForm} + permissions={permissions} Page={Page} /> diff --git a/packages/app-store/routing-forms/trpc/deleteForm.handler.ts b/packages/app-store/routing-forms/trpc/deleteForm.handler.ts index 0d67fc2b60..f7a39dc9af 100644 --- a/packages/app-store/routing-forms/trpc/deleteForm.handler.ts +++ b/packages/app-store/routing-forms/trpc/deleteForm.handler.ts @@ -1,12 +1,13 @@ import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server"; import type { PrismaClient } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import getConnectedForms from "../lib/getConnectedForms"; -import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed"; import type { TDeleteFormInputSchema } from "./deleteForm.schema"; +import { checkPermissionOnExistingRoutingForm } from "./permissions"; interface DeleteFormHandlerOptions { ctx: { @@ -17,11 +18,13 @@ interface DeleteFormHandlerOptions { } export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => { const { user, prisma } = ctx; - if (!(await isFormCreateEditAllowed({ userId: user.id, formId: input.id, targetTeamId: null }))) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } + + await checkPermissionOnExistingRoutingForm({ + formId: input.id, + userId: user.id, + permission: "routingForm.delete", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); const areFormsUsingIt = ( await getConnectedForms(prisma, { diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts index ed86357719..c60f83006b 100644 --- a/packages/app-store/routing-forms/trpc/formMutation.handler.ts +++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts @@ -1,8 +1,10 @@ import type { App_RoutingForms_Form } from "@prisma/client"; import { Prisma } from "@prisma/client"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server"; import type { PrismaClient } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; @@ -16,6 +18,7 @@ import isRouterLinkedField from "../lib/isRouterLinkedField"; import type { SerializableForm } from "../types/types"; import { zodFields, zodRouterRoute, zodRoutes } from "../zod"; import type { TFormMutationInputSchema } from "./formMutation.schema"; +import { checkPermissionOnExistingRoutingForm } from "./permissions"; interface FormMutationHandlerOptions { ctx: { @@ -30,10 +33,36 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt const { name, id, description, disabled, addFallback, duplicateFrom, shouldConnect } = input; let teamId = input.teamId; const settings = input.settings; - if (!(await isFormCreateEditAllowed({ userId: user.id, formId: id, targetTeamId: teamId }))) { - throw new TRPCError({ - code: "FORBIDDEN", + + const existingForm = await prisma.app_RoutingForms_Form.findUnique({ + where: { id }, + select: { id: true }, + }); + if (existingForm) { + // Check PBAC permissions for updating routing forms only + await checkPermissionOnExistingRoutingForm({ + formId: existingForm.id, + userId: user.id, + permission: "routingForm.update", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], }); + } else if (teamId) { + // Check PBAC permissions for creating team-scoped routing forms only + // Personal forms (teamId = null) are always allowed for the user + const permissionService = new PermissionCheckService(); + const hasPermission = await permissionService.checkPermission({ + userId: user.id, + teamId, + permission: "routingForm.create", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `You don't have permission to create routing forms for this team`, + }); + } } let { routes: inputRoutes, fields: inputFields } = input; diff --git a/packages/app-store/routing-forms/trpc/formQuery.handler.ts b/packages/app-store/routing-forms/trpc/formQuery.handler.ts index 3cf2090d1e..f518b02a71 100644 --- a/packages/app-store/routing-forms/trpc/formQuery.handler.ts +++ b/packages/app-store/routing-forms/trpc/formQuery.handler.ts @@ -1,9 +1,13 @@ import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server"; import type { PrismaClient } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; +import { TRPCError } from "@trpc/server"; + import { getSerializableForm } from "../lib/getSerializableForm"; import type { TFormQueryInputSchema } from "./formQuery.schema"; +import { checkPermissionOnExistingRoutingForm } from "./permissions"; interface FormsHandlerOptions { ctx: { @@ -38,6 +42,13 @@ export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => { return null; } + await checkPermissionOnExistingRoutingForm({ + formId: input.id, + userId: user.id, + permission: "routingForm.read", + fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }); + return await getSerializableForm({ form }); }; diff --git a/packages/app-store/routing-forms/trpc/permissions.ts b/packages/app-store/routing-forms/trpc/permissions.ts new file mode 100644 index 0000000000..042533551d --- /dev/null +++ b/packages/app-store/routing-forms/trpc/permissions.ts @@ -0,0 +1,56 @@ +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +export async function checkPermissionOnExistingRoutingForm({ + formId, + userId, + permission, + fallbackRoles, +}: { + formId: string; + userId: number; + permission: PermissionString; + fallbackRoles: MembershipRole[]; +}) { + // First get the form to check its team context + const form = await PrismaRoutingFormRepository.findById(formId, { + select: { teamId: true, userId: true }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + + // Check PBAC permissions for personal-scoped routing forms only + if (!form.teamId && form.userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to this personal-scoped routing form", + }); + } + + // Check PBAC permissions for team-scoped routing forms only + if (form.teamId) { + const permissionService = new PermissionCheckService(); + const hasPermission = await permissionService.checkPermission({ + userId: userId, + teamId: form.teamId, + permission, + fallbackRoles, + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `You don't have "${permission}" permission for this routing form`, + }); + } + } +} diff --git a/packages/app-store/routing-forms/types/shared.ts b/packages/app-store/routing-forms/types/shared.ts index 728d53664f..7cc707a548 100644 --- a/packages/app-store/routing-forms/types/shared.ts +++ b/packages/app-store/routing-forms/types/shared.ts @@ -16,4 +16,5 @@ export type SingleFormComponentProps = { enrichedWithUserProfileForm: inferSSRProps< typeof getServerSidePropsForSingleFormView >["enrichedWithUserProfileForm"]; + permissions: inferSSRProps["permissions"]; }; diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 5fb120db9f..1f41ce55e9 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -7,6 +7,7 @@ export enum Resource { Booking = "booking", Insights = "insights", Role = "role", + RoutingForm = "routingForm", Workflow = "workflow", } @@ -421,4 +422,40 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { descriptionI18nKey: "pbac_desc_create_organization_attributes", }, }, + [Resource.RoutingForm]: { + _resource: { + i18nKey: "pbac_resource_routing_form", + }, + [CrudAction.Create]: { + description: "Create routing forms", + category: "routing", + i18nKey: "pbac_action_create", + descriptionI18nKey: "pbac_desc_create_routing_forms", + }, + [CrudAction.Read]: { + description: "View routing forms", + category: "routing", + i18nKey: "pbac_action_read", + descriptionI18nKey: "pbac_desc_view_routing_forms", + }, + [CrudAction.Update]: { + description: "Update routing forms", + category: "routing", + i18nKey: "pbac_action_update", + descriptionI18nKey: "pbac_desc_update_routing_forms", + }, + [CrudAction.Delete]: { + description: "Delete routing forms", + category: "routing", + i18nKey: "pbac_action_delete", + descriptionI18nKey: "pbac_desc_delete_routing_forms", + }, + [CrudAction.Manage]: { + description: "Manage routing forms", + category: "routing", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_routing_forms", + scope: [Scope.Organization], + }, + }, }; diff --git a/packages/lib/server/getRoutedUrl.test.ts b/packages/lib/server/getRoutedUrl.test.ts index cce4e7650b..20a93de6d7 100644 --- a/packages/lib/server/getRoutedUrl.test.ts +++ b/packages/lib/server/getRoutedUrl.test.ts @@ -14,7 +14,7 @@ import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pag import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm"; +import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository"; import { UserRepository } from "@calcom/lib/server/repository/user"; import { getRoutedUrl } from "./getRoutedUrl"; @@ -22,7 +22,7 @@ import { getRoutedUrl } from "./getRoutedUrl"; // Mock dependencies vi.mock("@calcom/lib/checkRateLimitAndThrowError"); vi.mock("@calcom/app-store/routing-forms/lib/handleResponse"); -vi.mock("@calcom/lib/server/repository/routingForm"); +vi.mock("@calcom/lib/server/repository/PrismaRoutingFormRepository"); vi.mock("@calcom/lib/server/repository/user", () => { return { UserRepository: vi.fn().mockImplementation(() => ({ @@ -83,8 +83,8 @@ describe("getRoutedUrl", () => { beforeEach(() => { vi.resetAllMocks(); // Provide default mock implementations - vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null }); - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null); + vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null, isValidOrgDomain: false }); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null); const mockEnrichUserWithItsProfile = vi.fn().mockImplementation(async ({ user }) => user); const mockUserRepository = vi.mocked(UserRepository); @@ -129,16 +129,18 @@ describe("getRoutedUrl", () => { }); it("should return notFound if form is not found", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null); const context = mockContext({}); const result = await getRoutedUrl(context); expect(result).toEqual({ notFound: true }); - expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id"); + expect(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id"); }); it("should return notFound if user is not authorized", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(false); const context = mockContext({}); @@ -153,7 +155,9 @@ describe("getRoutedUrl", () => { }); it("should throw an error if no matching route is found", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never); vi.mocked(findMatchingRoute).mockReturnValue(null); @@ -167,7 +171,9 @@ describe("getRoutedUrl", () => { }); it("should return props with a message for customPageMessage action", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); const message = "This is a custom message"; const mockRoute = { id: "route1", action: { type: "customPageMessage", value: message } }; vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); @@ -196,7 +202,9 @@ describe("getRoutedUrl", () => { }); it("should return a redirect for eventTypeRedirectUrl action and substitute variables", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); const redirectUrl = "test-user/30min/{email}"; const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } }; vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); @@ -233,7 +241,9 @@ describe("getRoutedUrl", () => { }); it("should return a redirect for externalRedirectUrl action", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); const redirectUrl = "https://example.com"; const mockRoute = { id: "route1", action: { type: "externalRedirectUrl", value: redirectUrl } }; vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); @@ -259,7 +269,9 @@ describe("getRoutedUrl", () => { }); it("should call handleResponse with correct response prop obtained from getResponseToStore", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); const redirectUrl = "test-user/30min"; const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } }; vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); @@ -294,7 +306,9 @@ describe("getRoutedUrl", () => { describe("Dry Run", () => { it("should call handleResponse with isPreview:true", async () => { - vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue( + mockForm as never + ); const redirectUrl = "test-user/30min"; const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } }; vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); diff --git a/packages/lib/server/getRoutedUrl.ts b/packages/lib/server/getRoutedUrl.ts index d257dda360..1702c697da 100644 --- a/packages/lib/server/getRoutedUrl.ts +++ b/packages/lib/server/getRoutedUrl.ts @@ -21,7 +21,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; -import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm"; +import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository"; import { UserRepository } from "@calcom/lib/server/repository/user"; import prisma from "@calcom/prisma"; @@ -94,7 +94,7 @@ const _getRoutedUrl = async (context: Pick = {}; const formQueryStart = performance.now(); - const form = await RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId); + const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId); timeTaken.formQuery = performance.now() - formQueryStart; if (!form) { diff --git a/packages/lib/server/repository/PrismaRoutingFormRepository.ts b/packages/lib/server/repository/PrismaRoutingFormRepository.ts new file mode 100644 index 0000000000..d71c070edd --- /dev/null +++ b/packages/lib/server/repository/PrismaRoutingFormRepository.ts @@ -0,0 +1,75 @@ +import prisma from "@calcom/prisma"; + +import type { + RoutingFormSelect, + SelectedFields, + FindByIdOptions, + RoutingFormWithUserTeamAndOrg, +} from "./PrismaRoutingFormRepositoryInterface"; + +const defaultSelect = { + id: true, + description: true, + position: true, + routes: true, + createdAt: true, + updatedAt: true, + name: true, + fields: true, + updatedById: true, + userId: true, + teamId: true, + disabled: true, + settings: true, +} as const; + +export class PrismaRoutingFormRepository { + static async findById( + id: string, + options?: FindByIdOptions + ): Promise | null> { + const select = options?.select ?? defaultSelect; + return (await prisma.app_RoutingForms_Form.findUnique({ + where: { id }, + select, + })) as SelectedFields | null; + } + + static async findFormByIdIncludeUserTeamAndOrg( + formId: string + ): Promise { + return (await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: formId, + }, + include: { + user: { + select: { + id: true, + username: true, + email: true, + movedToProfileId: true, + metadata: true, + organization: { + select: { + slug: true, + }, + }, + }, + }, + team: { + select: { + parentId: true, + parent: { + select: { + slug: true, + }, + }, + slug: true, + metadata: true, + }, + }, + }, + })) as RoutingFormWithUserTeamAndOrg | null; + } +} diff --git a/packages/lib/server/repository/PrismaRoutingFormRepositoryInterface.ts b/packages/lib/server/repository/PrismaRoutingFormRepositoryInterface.ts new file mode 100644 index 0000000000..f278866b24 --- /dev/null +++ b/packages/lib/server/repository/PrismaRoutingFormRepositoryInterface.ts @@ -0,0 +1,78 @@ +// Dedicated type definition for routing form data +export type RoutingForm = { + id: string; + description: string | null; + position: number; + routes: any; // JSON field + createdAt: Date; + updatedAt: Date; + name: string; + fields: any; // JSON field + updatedById: number | null; + userId: number | null; + teamId: number | null; + disabled: boolean; + settings: any; // JSON field +}; + +// Helper type for select parameter +export type RoutingFormSelect = { + [K in keyof RoutingForm]?: boolean; +}; + +// Helper type for selected fields +export type SelectedFields = T extends undefined + ? RoutingForm + : { + [K in keyof T as T[K] extends true ? K : never]: K extends keyof RoutingForm ? RoutingForm[K] : never; + }; + +// Type for findById options +export type FindByIdOptions = { + select?: T; +}; + +// Type for findFormByIdIncludeUserTeamAndOrg result +export type RoutingFormWithUserTeamAndOrg = { + id: string; + description: string | null; + position: number; + routes: any; + createdAt: Date; + updatedAt: Date; + name: string; + fields: any; + updatedById: number | null; + userId: number; + teamId: number | null; + disabled: boolean; + settings: any; + user: { + id: number; + metadata: any; + organization: { + slug: string | null; + } | null; + username: string | null; + email: string; + movedToProfileId: number | null; + }; + team: { + metadata: any; + slug: string | null; + parentId: number | null; + parent: { + slug: string | null; + } | null; + } | null; +}; + +// Interface for static methods (for documentation and type checking purposes) +export interface IPrismaRoutingFormRepositoryStatic { + findById( + id: string, + options?: FindByIdOptions + ): Promise | null>; + + findFormByIdIncludeUserTeamAndOrg(formId: string): Promise; +} diff --git a/packages/lib/server/repository/routingForm.ts b/packages/lib/server/repository/routingForm.ts deleted file mode 100644 index 4284df24dd..0000000000 --- a/packages/lib/server/repository/routingForm.ts +++ /dev/null @@ -1,39 +0,0 @@ -import prisma from "@calcom/prisma"; - -export class RoutingFormRepository { - static async findFormByIdIncludeUserTeamAndOrg(formId: string) { - return await prisma.app_RoutingForms_Form.findUnique({ - where: { - id: formId, - }, - include: { - user: { - select: { - id: true, - username: true, - email: true, - movedToProfileId: true, - metadata: true, - organization: { - select: { - slug: true, - }, - }, - }, - }, - team: { - select: { - parentId: true, - parent: { - select: { - slug: true, - }, - }, - slug: true, - metadata: true, - }, - }, - }, - }); - } -} diff --git a/packages/prisma/migrations/20250806093054_insert_role_permissions_for_routing_form_pbac/migration.sql b/packages/prisma/migrations/20250806093054_insert_role_permissions_for_routing_form_pbac/migration.sql new file mode 100644 index 0000000000..164791f421 --- /dev/null +++ b/packages/prisma/migrations/20250806093054_insert_role_permissions_for_routing_form_pbac/migration.sql @@ -0,0 +1,23 @@ +-- Add routing form permissions for admin role (create, read, update, delete) +INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt") +SELECT + gen_random_uuid(), 'admin_role', resource, action, NOW() +FROM ( + VALUES + ('routingForm', 'create'), + ('routingForm', 'read'), + ('routingForm', 'update'), + ('routingForm', 'delete'), + ('routingForm', 'manage') +) AS permissions(resource, action) +ON CONFLICT ("roleId", resource, action) DO NOTHING; + +-- Add routing form read permission for member role +INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt") +SELECT + gen_random_uuid(), 'member_role', resource, action, NOW() +FROM ( + VALUES + ('routingForm', 'read') +) AS permissions(resource, action) +ON CONFLICT ("roleId", resource, action) DO NOTHING; diff --git a/packages/ui/components/list/List.tsx b/packages/ui/components/list/List.tsx index 09dd61eb47..c40ff7a066 100644 --- a/packages/ui/components/list/List.tsx +++ b/packages/ui/components/list/List.tsx @@ -70,11 +70,20 @@ export type ListLinkItemProps = { heading: string; subHeading: string; disabled?: boolean; + readOnly?: boolean; actions?: JSX.Element; } & JSX.IntrinsicElements["li"]; export function ListLinkItem(props: ListLinkItemProps) { - const { href, heading = "", children, disabled = false, actions =
, className = "" } = props; + const { + href, + heading = "", + children, + disabled = false, + readOnly = false, + actions =
, + className = "", + } = props; const { t } = useLocale(); let subHeading = props.subHeading; if (!subHeading) { @@ -97,7 +106,7 @@ export function ListLinkItem(props: ListLinkItemProps) { )}>

{heading}

- {disabled && ( + {readOnly && ( {t("readonly")} diff --git a/packages/ui/components/list/list.test.tsx b/packages/ui/components/list/list.test.tsx index 97ce274ecf..3039114e3d 100644 --- a/packages/ui/components/list/list.test.tsx +++ b/packages/ui/components/list/list.test.tsx @@ -64,6 +64,18 @@ describe("Tests for ListLinkItem component", () => { ); + const listLinkItemElement = screen.getByTestId("list-link-item"); + const link = listLinkItemElement.firstChild; + expect(link).toHaveClass("pointer-events-none", "cursor-not-allowed", "opacity-30"); + }); + + test("Should show readonly badge", () => { + render( + + Alright + + ); + const badge = screen.getByTestId("badge"); expect(badge).toBeInTheDocument(); });