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:
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],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user