fix: apply PBAC to routing form CRUD (#22859)

* fix: apply PBAC to routing form CRUD

* apply permission checks to the UI

* moving prisma call to repository [WIP]

* rename repository and fix type error

* update implementation

* fix formMutation handler

* remove unused import

* revert some rename

* add RolePermission for 'routingForm'

* Revert "revert some rename"

This reverts commit 0ef3114c0d27b821271b36296de531654b37749f.

* clean up PrismaRoutingFormRepository

* fix unit test

* remove no longer necessary code

* fix type definition

* explicit permission handling

* do not disable un-editable routing form

* fix: correct property name from readonly to readOnly in ListLinkItem

- Fix TypeScript error in routing forms component
- Change readonly={readOnly} to readOnly={readOnly} to match component interface
- Resolves type check failure in CI

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-08-08 13:18:18 +01:00
1 parent ee1904bb2c
commit 6fe6540bc6
22 files changed
+471 -78

No files matched your search

@@ -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<NewFormDialogState>(null);
@@ -153,6 +159,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
setShowInfoLostDialog={setShowInfoLostDialog}
setIsTestPreviewOpen={setIsTestPreviewOpen}
isTestPreviewOpen={isTestPreviewOpen}
permissions={permissions}
/>
<div
className={classNames(
@@ -59,6 +59,7 @@ const Actions = ({
setIsTestPreviewOpen,
isTestPreviewOpen,
isMobile = false,
permissions,
}: {
form: RoutingFormWithResponseCount;
setIsTestPreviewOpen: (value: boolean) => 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<RoutingFormWithResponseCount>();
@@ -165,6 +172,7 @@ const Actions = ({
className="w-full"
type="button"
color="destructive"
disabled={!permissions.canDelete}
StartIcon="trash">
{t("delete")}
</FormAction>
@@ -182,6 +190,7 @@ const Actions = ({
<Button
data-testid={isMobile ? "update-form-mobile" : "update-form"}
loading={isSaving}
disabled={!permissions.canEdit}
type="submit"
color="primary">
{t("save")}
@@ -206,6 +215,7 @@ export function Header({
setShowInfoLostDialog,
setIsTestPreviewOpen,
isTestPreviewOpen,
permissions,
}: {
routingForm: RoutingFormWithResponseCount;
isSaving: boolean;
@@ -213,6 +223,12 @@ export function Header({
setShowInfoLostDialog: (value: boolean) => void;
setIsTestPreviewOpen: (value: boolean) => void;
isTestPreviewOpen: boolean;
permissions: {
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
};
}) {
const { t } = useLocale();
const [isEditing, setIsEditing] = useState(false);
@@ -324,7 +340,8 @@ export function Header({
isSaving={isSaving}
appUrl={appUrl}
isMobile={true}
/>
permissions={permissions}
/>{" "}
</div>
</div>
@@ -357,6 +374,7 @@ export function Header({
isTestPreviewOpen={isTestPreviewOpen}
isSaving={isSaving}
appUrl={appUrl}
permissions={permissions}
/>
</div>
</div>
@@ -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,
},
},
};
};
@@ -348,6 +348,7 @@ const FormEdit = ({
export default function FormEditPage({
appUrl,
permissions,
...props
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
@@ -356,6 +357,7 @@ export default function FormEditPage({
<SingleForm
{...props}
appUrl={appUrl}
permissions={permissions}
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
/>
</>
@@ -218,7 +218,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
<ListLinkItem
href={`${appUrl}/form-edit/${form.id}`}
heading={form.name}
disabled={readOnly}
readOnly={readOnly}
subHeading={description}
className="space-x-2 rtl:space-x-reverse"
actions={
@@ -320,12 +320,14 @@ export default function IncompleteBookingPage({
form,
appUrl,
enrichedWithUserProfileForm,
permissions,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
permissions={permissions}
Page={Page}
/>
);
@@ -266,6 +266,7 @@ const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["fo
};
export default function ReporterWrapper({
permissions,
...props
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
const [isClient, setIsClient] = useState(false);
@@ -280,6 +281,7 @@ export default function ReporterWrapper({
return (
<SingleForm
{...props}
permissions={permissions}
Page={({ form }) => (
<div className="route-config bg-default fixed inset-0 w-full overflow-scroll pt-12 ltr:mr-2 rtl:ml-2 sm:pt-0">
{isClient && <Reporter form={form} />}
@@ -1429,6 +1429,7 @@ export default function RouteBuilder({
form,
appUrl,
enrichedWithUserProfileForm,
permissions,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<>
@@ -1436,6 +1437,7 @@ export default function RouteBuilder({
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
permissions={permissions}
Page={Page}
/>
<Toaster position="bottom-right" />
@@ -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, {
@@ -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;
@@ -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 });
};
@@ -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`,
});
}
}
}
@@ -16,4 +16,5 @@ export type SingleFormComponentProps = {
enrichedWithUserProfileForm: inferSSRProps<
typeof getServerSidePropsForSingleFormView
>["enrichedWithUserProfileForm"];
permissions: inferSSRProps<typeof getServerSidePropsForSingleFormView>["permissions"];
};
@@ -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],
},
},
};
+27 -13
View File
@@ -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);
+2 -2
View File
@@ -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<GetServerSidePropsContext, "query" |
let timeTaken: Record<string, number | null> = {};
const formQueryStart = performance.now();
const form = await RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
timeTaken.formQuery = performance.now() - formQueryStart;
if (!form) {
@@ -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<T extends RoutingFormSelect | undefined = undefined>(
id: string,
options?: FindByIdOptions<T>
): Promise<SelectedFields<T> | null> {
const select = options?.select ?? defaultSelect;
return (await prisma.app_RoutingForms_Form.findUnique({
where: { id },
select,
})) as SelectedFields<T> | null;
}
static async findFormByIdIncludeUserTeamAndOrg(
formId: string
): Promise<RoutingFormWithUserTeamAndOrg | null> {
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;
}
}
@@ -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> = 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<T extends RoutingFormSelect | undefined = undefined> = {
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<T extends RoutingFormSelect | undefined = undefined>(
id: string,
options?: FindByIdOptions<T>
): Promise<SelectedFields<T> | null>;
findFormByIdIncludeUserTeamAndOrg(formId: string): Promise<RoutingFormWithUserTeamAndOrg | 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,
},
},
},
});
}
}
@@ -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;
+11 -2
View File
@@ -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 = <div />, className = "" } = props;
const {
href,
heading = "",
children,
disabled = false,
readOnly = false,
actions = <div />,
className = "",
} = props;
const { t } = useLocale();
let subHeading = props.subHeading;
if (!subHeading) {
@@ -97,7 +106,7 @@ export function ListLinkItem(props: ListLinkItemProps) {
)}>
<div className="flex items-center">
<h1 className="text-sm font-semibold leading-none">{heading}</h1>
{disabled && (
{readOnly && (
<Badge data-testid="badge" variant="gray" className="ml-2">
{t("readonly")}
</Badge>
+12
View File
@@ -64,6 +64,18 @@ describe("Tests for ListLinkItem component", () => {
</ListLinkItem>
);
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(
<ListLinkItem href="https://custom.link" heading="Go" subHeading="There" readOnly={true}>
Alright
</ListLinkItem>
);
const badge = screen.getByTestId("badge");
expect(badge).toBeInTheDocument();
});