feat: enable PBAC checking on organization settings page (#22467)

This commit is contained in:
sean-brydon
2025-07-31 00:56:25 +00:00
committed by GitHub
parent 5360cb511d
commit 4b2f89d6e8
26 changed files with 936 additions and 105 deletions
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,40 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Attributes,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
<OrgSettingsAttributesPage />
<OrgSettingsAttributesPage permissions={{ canEdit, canDelete, canCreate }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
<DirectorySyncTeamView />
<DirectorySyncTeamView permissions={{ canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,34 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
<PrivacyView />
<PrivacyView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
<OrgSSOView />
<OrgSSOView permissions={{ canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,30 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<LegacyPage />
<LegacyPage permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,16 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -13,14 +22,47 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const t = await getTranslate();
const orgRole = session?.user.profile?.organization.members?.find(
(member: Membership) => member.userId === session?.user.id
)?.role;
if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete } = await getResourcePermissions({
userId: session.user.id,
teamId: session?.user.profile?.organizationId,
resource: Resource.Organization,
userRole: orgRole,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader
title={t("profile")}
description={t("profile_org_description")}
borderInShellHeader={true}>
<LegacyPage />
<LegacyPage
permissions={{
canEdit,
canRead,
canDelete,
}}
/>
</SettingsHeader>
);
};
@@ -871,6 +871,10 @@
"create_team": "Create Team",
"name": "Name",
"nameless_team": "Nameless Team",
"oauth_clients": "OAuth Clients",
"oauth_clients_description": "Manage OAuth clients for your organization",
"create_oauth_client": "Create OAuth Client",
"create_oauth_client_description": "Create a new OAuth client for third-party integrations",
"oauth_client_deletion_message": "OAuth client deleted successfully",
"create_new_team_description": "Create a new team to collaborate with users.",
"create_new_team": "Create a new team",
@@ -1836,6 +1840,8 @@
"edit_event_type": "Edit event type",
"only_admin_can_see_members_of_org": "This Organization is private, and only the organization's admin or owner can view its members.",
"only_admin_can_manage_sso_org": "Only the organization's admin or owner can manage SSO settings",
"only_admin_can_manage_directory_sync": "Only the organization's admin or owner can manage directory sync settings",
"only_admin_can_manage_oauth_clients": "Only the organization's admin or owner can manage OAuth clients",
"collective_scheduling": "Collective Scheduling",
"make_it_easy_to_book": "Make it easy to book your team when everyone is available.",
"find_the_best_person": "Find the best person available and cycle through your team.",
@@ -3302,6 +3308,11 @@
"error_creating_role": "Error creating role",
"error_updating_role": "Error updating role",
"pbac_desc_create_roles": "Create roles",
"pbac_resource_attributes": "Attributes",
"pbac_desc_view_organization_attributes": "View organization attributes",
"pbac_desc_update_organization_attributes": "Update organization attributes",
"pbac_desc_delete_organization_attributes": "Delete organization attributes",
"pbac_desc_create_organization_attributes": "Create organization attributes",
"pbac_desc_view_roles": "View roles",
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
@@ -12,7 +12,7 @@ import { showToast } from "@calcom/ui/components/toast";
import ConfigureDirectorySync from "../components/ConfigureDirectorySync";
// For Hosted Cal - Team view
const DirectorySync = () => {
const DirectorySync = ({ permissions }: { permissions?: { canEdit: boolean } }) => {
const { t } = useLocale();
const router = useRouter();
@@ -36,6 +36,10 @@ const DirectorySync = () => {
showToast(error.message, "error");
}
if (!permissions?.canEdit) {
router.push("/404");
}
return (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
{HOSTED_CAL_FEATURES && <ConfigureDirectorySync organizationId={currentOrg?.id || null} />}
@@ -10,10 +10,9 @@ import { showToast } from "@calcom/ui/components/toast";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}
export const DisablePhoneOnlySMSNotificationsSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
export const DisablePhoneOnlySMSNotificationsSwitch = ({ currentOrg }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [disablePhoneOnlySMSNotificationsActive, setDisablePhoneOnlySMSNotificationsActive] = useState(
@@ -32,8 +31,6 @@ export const DisablePhoneOnlySMSNotificationsSwitch = ({ currentOrg, isAdminOrOw
},
});
if (!isAdminOrOwner) return null;
return (
<>
<SettingsToggle
@@ -20,14 +20,13 @@ enum CurrentEventTypeOptions {
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}
interface FormValues {
currentEventTypeOptions: CurrentEventTypeOptions;
}
export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
export const LockEventTypeSwitch = ({ currentOrg }: GeneralViewProps) => {
const [lockEventTypeCreationForUsers, setLockEventTypeCreationForUsers] = useState(
!!currentOrg.organizationSettings.lockEventTypeCreationForUsers
);
@@ -50,8 +49,6 @@ export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewP
},
});
if (!isAdminOrOwner) return null;
const currentLockedOption = formMethods.watch("currentEventTypeOptions");
const { reset, getValues } = formMethods;
@@ -69,7 +66,7 @@ export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewP
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("lock_org_users_eventtypes")}
disabled={mutation?.isPending || !isAdminOrOwner}
disabled={mutation?.isPending}
description={t("lock_org_users_eventtypes_description")}
checked={lockEventTypeCreationForUsers}
onCheckedChange={(checked) => {
@@ -124,9 +121,7 @@ export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewP
<DialogFooter>
<DialogClose />
<Button disabled={!isAdminOrOwner} type="submit">
{t("submit")}
</Button>
<Button type="submit">{t("submit")}</Button>
</DialogFooter>
</div>
</div>
@@ -8,10 +8,9 @@ import { showToast } from "@calcom/ui/components/toast";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}
export const NoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
export const NoSlotsNotificationSwitch = ({ currentOrg }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [notificationActive, setNotificationActive] = useState(
@@ -30,8 +29,6 @@ export const NoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: Genera
},
});
if (!isAdminOrOwner) return null;
return (
<>
<SettingsToggle
@@ -36,9 +36,11 @@ const TypeToLabelMap = {
function AttributeItem({
attribute,
setAttributeToDelete,
permissions,
}: {
attribute: AttributeItemProps;
setAttributeToDelete: Dispatch<SetStateAction<AttributeItemProps | undefined>>;
permissions: { canEdit: boolean; canDelete: boolean };
}) {
const { t } = useLocale();
const [isEnabled, setIsEnabled] = useState(attribute.enabled);
@@ -87,44 +89,54 @@ function AttributeItem({
</p>
</div>
<div className="flex gap-4">
<Switch checked={isEnabled} onCheckedChange={handleToggle} />
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="icon"
color="secondary"
StartIcon="ellipsis"
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[200px]">
<DropdownMenuItem>
<DropdownItem
{permissions.canEdit && <Switch checked={isEnabled} onCheckedChange={handleToggle} />}
{(permissions.canEdit || permissions.canDelete) && (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
StartIcon="pencil"
href={`/settings/organizations/attributes/${attribute.id}/edit`}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="trash-2"
color="destructive"
className="rounded-t-none"
onClick={() => setAttributeToDelete(attribute)}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
variant="icon"
color="secondary"
StartIcon="ellipsis"
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[200px]">
{permissions.canEdit && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
href={`/settings/organizations/attributes/${attribute.id}/edit`}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissions.canDelete && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="trash-2"
color="destructive"
className="rounded-t-none"
onClick={() => setAttributeToDelete(attribute)}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
)}
</div>
</ul>
);
}
function OrganizationAttributesPage() {
function OrganizationAttributesPage({
permissions,
}: {
permissions: { canEdit: boolean; canDelete: boolean; canCreate: boolean };
}) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.attributes.list.useQuery();
const [attributeToDelete, setAttributeToDelete] = useState<AttributeItemProps>();
@@ -149,17 +161,20 @@ function OrganizationAttributesPage() {
<AttributeItem
setAttributeToDelete={setAttributeToDelete}
attribute={attribute}
permissions={{ canEdit: permissions.canEdit, canDelete: permissions.canDelete }}
key={attribute.id}
/>
))}
</li>
<Button
className="w-fit"
StartIcon="plus"
color="minimal"
href="/settings/organizations/attributes/create">
{t("add")}
</Button>
{permissions.canCreate && (
<Button
className="w-fit"
StartIcon="plus"
color="minimal"
href="/settings/organizations/attributes/create">
{t("add")}
</Button>
)}
</>
) : (
<div className="flex w-full flex-col items-center justify-center p-14">
@@ -173,13 +188,15 @@ function OrganizationAttributesPage() {
<p className="text-emphasis mt-3 text-sm font-normal leading-none">
{t("add_attributes_description")}
</p>
<Button
className="mt-8"
StartIcon="plus"
color="secondary"
href="/settings/organizations/attributes/create">
{t("new_attribute")}
</Button>
{permissions.canCreate && (
<Button
className="mt-8"
StartIcon="plus"
color="secondary"
href="/settings/organizations/attributes/create">
{t("new_attribute")}
</Button>
)}
</div>
)}
</div>
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
@@ -42,22 +41,31 @@ const SkeletonLoader = () => {
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
localeProp: string;
permissions: {
canRead: boolean;
canEdit: boolean;
};
}
const OrgGeneralView = () => {
const OrgGeneralView = ({
permissions,
}: {
permissions: {
canRead: boolean;
canEdit: boolean;
};
}) => {
const { t } = useLocale();
const router = useRouter();
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
const {
data: currentOrg,
isPending,
error,
} = trpc.viewer.organizations.listCurrent.useQuery(undefined, {});
const { data: user } = trpc.viewer.me.get.useQuery();
useEffect(
function refactorMeWithoutEffect() {
@@ -77,18 +85,22 @@ const OrgGeneralView = () => {
<LicenseRequired>
<GeneralView
currentOrg={currentOrg}
isAdminOrOwner={isAdminOrOwner}
localeProp={user?.locale ?? "en"}
localeProp={session.data?.user.locale ?? "en"}
permissions={permissions}
/>
<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
<NoSlotsNotificationSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
<DisablePhoneOnlySMSNotificationsSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
{permissions.canEdit && (
<>
<LockEventTypeSwitch currentOrg={currentOrg} />
<NoSlotsNotificationSwitch currentOrg={currentOrg} />
<DisablePhoneOnlySMSNotificationsSwitch currentOrg={currentOrg} />
</>
)}
</LicenseRequired>
);
};
const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProps) => {
const GeneralView = ({ currentOrg, permissions, localeProp }: GeneralViewProps) => {
const { t } = useLocale();
const mutation = trpc.viewer.organizations.update.useMutation({
@@ -136,7 +148,7 @@ const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProp
reset,
getValues,
} = formMethods;
const isDisabled = isSubmitting || !isDirty || !isAdminOrOwner;
const isDisabled = isSubmitting || !isDirty || !permissions.canEdit;
return (
<Form
form={formMethods}
@@ -150,7 +162,7 @@ const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProp
<div
className={classNames(
"border-subtle border-x border-y-0 px-4 py-8 sm:px-6",
!isAdminOrOwner && "rounded-b-lg border-y"
!permissions.canEdit && "rounded-b-lg border-y"
)}>
<Controller
name="timeZone"
@@ -211,7 +223,7 @@ const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProp
/>
</div>
{isAdminOrOwner && (
{permissions?.canEdit && (
<SectionBottomActions align="end">
<Button disabled={isDisabled} color="primary" type="submit">
{t("update")}
@@ -1,16 +1,14 @@
"use client";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import MakeTeamPrivateSwitch from "@calcom/features/ee/teams/components/MakeTeamPrivateSwitch";
import { trpc } from "@calcom/trpc/react";
const PrivacyView = () => {
const PrivacyView = ({ permissions }: { permissions: { canRead: boolean; canEdit: boolean } }) => {
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner = currentOrg && checkAdminOrOwner(currentOrg.user.role);
const isInviteOpen = !currentOrg?.user.accepted;
const isDisabled = isInviteOpen || !isOrgAdminOrOwner;
const isDisabled = !permissions.canEdit || isInviteOpen;
if (!currentOrg) return null;
@@ -7,7 +7,6 @@ import { useEffect, useLayoutEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import OrgAppearanceViewWrapper from "@calcom/features/ee/organizations/pages/settings/appearance";
@@ -76,7 +75,15 @@ const SkeletonLoader = () => {
);
};
const OrgProfileView = () => {
const OrgProfileView = ({
permissions,
}: {
permissions?: {
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
};
}) => {
const { t } = useLocale();
const router = useRouter();
@@ -105,8 +112,6 @@ const OrgProfileView = () => {
return <SkeletonLoader />;
}
const isOrgAdminOrOwner = checkAdminOrOwner(currentOrganisation.user.role);
const isBioEmpty =
!currentOrganisation ||
!currentOrganisation.bio ||
@@ -128,7 +133,7 @@ const OrgProfileView = () => {
return (
<LicenseRequired>
<>
{isOrgAdminOrOwner ? (
{permissions?.canEdit ? (
<>
<OrgProfileForm defaultValues={defaultValues} />
<OrgAppearanceViewWrapper />
@@ -3,25 +3,31 @@
import { useSession } from "next-auth/react";
import { SkeletonLoader } from "@calcom/features/apps/components/SkeletonLoader";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import SSOConfiguration from "../components/SSOConfiguration";
const SAMLSSO = () => {
interface OrgSSOViewProps {
permissions?: {
canEdit: boolean;
};
}
const SAMLSSO = ({ permissions }: OrgSSOViewProps) => {
const { t } = useLocale();
const { data, status } = useSession();
const isAdminOrOwner = checkAdminOrOwner(data?.user?.org?.role);
const org = data?.user.org;
if (status === "loading") <SkeletonLoader />;
if (status === "loading") return <SkeletonLoader />;
if (!org) {
return null;
}
return !!isAdminOrOwner ? (
const canEdit = permissions?.canEdit ?? false;
return canEdit ? (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<SSOConfiguration teamId={org.id} />
</div>
@@ -46,7 +46,11 @@ export class PermissionMapper {
throw new Error(`Invalid permission format: ${permissionString}`);
}
const [resource, action] = permissionString.split(".", 2);
// Split from the right side to handle resources with dots
const lastDotIndex = permissionString.lastIndexOf(".");
const resource = permissionString.substring(0, lastDotIndex);
const action = permissionString.substring(lastDotIndex + 1);
if (!resource || !action) {
throw new Error(`Invalid permission format: ${permissionString}`);
}
@@ -3,6 +3,7 @@ export enum Resource {
EventType = "eventType",
Team = "team",
Organization = "organization",
Attributes = "organization.attributes",
Booking = "booking",
Insights = "insights",
Role = "role",
@@ -354,4 +355,33 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_view_team_insights",
},
},
[Resource.Attributes]: {
_resource: {
i18nKey: "pbac_resource_attributes",
},
[CrudAction.Read]: {
description: "View organization attributes",
category: "attributes",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_organization_attributes",
},
[CrudAction.Update]: {
description: "Update organization attributes",
category: "attributes",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_organization_attributes",
},
[CrudAction.Delete]: {
description: "Delete organization attributes",
category: "attributes",
i18nKey: "pbac_action_delete",
descriptionI18nKey: "pbac_desc_delete_organization_attributes",
},
[CrudAction.Create]: {
description: "Create organization attributes",
category: "attributes",
i18nKey: "pbac_action_create",
descriptionI18nKey: "pbac_desc_create_organization_attributes",
},
},
};
@@ -0,0 +1,278 @@
import { vi, type Mock, describe, it, expect, beforeEach } from "vitest";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { MembershipRole } from "@calcom/prisma/enums";
import { PermissionMapper } from "../../domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "../../domain/types/permission-registry";
import { PermissionCheckService } from "../../services/permission-check.service";
import { getResourcePermissions } from "../resource-permissions";
vi.mock("@calcom/features/flags/features.repository");
vi.mock("../../services/permission-check.service");
vi.mock("../../domain/mappers/PermissionMapper", () => ({
PermissionMapper: {
toActionMap: vi.fn(),
},
}));
describe("getResourcePermissions", () => {
let mockFeaturesRepository: {
checkIfTeamHasFeature: Mock;
};
let mockPermissionCheckService: {
getResourcePermissions: Mock;
};
beforeEach(() => {
vi.clearAllMocks();
mockFeaturesRepository = {
checkIfTeamHasFeature: vi.fn(),
};
mockPermissionCheckService = {
getResourcePermissions: vi.fn(),
};
vi.mocked(FeaturesRepository).mockImplementation(() => mockFeaturesRepository as any);
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
});
describe("when PBAC is disabled", () => {
beforeEach(() => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
});
it("should use fallback roles for permissions", async () => {
const result = await getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.OWNER,
fallbackRoles: {
read: { roles: [MembershipRole.OWNER, MembershipRole.ADMIN] },
create: { roles: [MembershipRole.OWNER] },
update: { roles: [MembershipRole.OWNER, MembershipRole.ADMIN] },
delete: { roles: [MembershipRole.OWNER] },
},
});
expect(result).toEqual({
canRead: true,
canCreate: true,
canEdit: true,
canDelete: true,
});
});
it("should deny permissions for roles not in fallback configuration", async () => {
const result = await getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.MEMBER,
fallbackRoles: {
read: { roles: [MembershipRole.OWNER, MembershipRole.ADMIN] },
create: { roles: [MembershipRole.OWNER] },
update: { roles: [MembershipRole.OWNER] },
delete: { roles: [MembershipRole.OWNER] },
},
});
expect(result).toEqual({
canRead: false,
canCreate: false,
canEdit: false,
canDelete: false,
});
});
it("should handle conditional role access", async () => {
const condition = vi.fn().mockReturnValue(true);
const result = await getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.ADMIN,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN],
condition,
},
},
});
expect(condition).toHaveBeenCalledWith(MembershipRole.ADMIN);
expect(result.canRead).toBe(true);
});
it("should deny access when condition returns false", async () => {
const condition = vi.fn().mockReturnValue(false);
const result = await getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.ADMIN,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN],
condition,
},
},
});
expect(condition).toHaveBeenCalledWith(MembershipRole.ADMIN);
expect(result.canRead).toBe(false);
});
it("should return false for undefined fallback mappings", async () => {
const result = await getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.OWNER,
fallbackRoles: {},
});
expect(result).toEqual({
canRead: false,
canCreate: false,
canEdit: false,
canDelete: false,
});
});
});
describe("when PBAC is enabled", () => {
beforeEach(() => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
});
it("should get permissions from PermissionCheckService", async () => {
const mockResourcePermissions = ["team.read", "team.update"];
mockPermissionCheckService.getResourcePermissions.mockResolvedValue(mockResourcePermissions);
vi.mocked(PermissionMapper.toActionMap).mockReturnValue({
[CrudAction.Read]: true,
[CrudAction.Update]: true,
[CrudAction.Delete]: false,
[CrudAction.Create]: false,
} as any);
const result = await getResourcePermissions({
userId: 1,
teamId: 2,
resource: Resource.Team,
userRole: MembershipRole.MEMBER,
});
expect(mockPermissionCheckService.getResourcePermissions).toHaveBeenCalledWith({
userId: 1,
teamId: 2,
resource: Resource.Team,
});
expect(PermissionMapper.toActionMap).toHaveBeenCalledWith(mockResourcePermissions, Resource.Team);
expect(result).toEqual({
canRead: true,
canEdit: true,
canDelete: false,
canCreate: false,
});
});
it("should handle null values from action map", async () => {
mockPermissionCheckService.getResourcePermissions.mockResolvedValue([]);
vi.mocked(PermissionMapper.toActionMap).mockReturnValue({
[CrudAction.Read]: null,
[CrudAction.Update]: undefined,
} as any);
const result = await getResourcePermissions({
userId: 1,
teamId: 2,
resource: Resource.Organization,
userRole: MembershipRole.MEMBER,
});
expect(result).toEqual({
canRead: false,
canEdit: false,
canDelete: false,
canCreate: false,
});
});
it("should handle different resources", async () => {
mockPermissionCheckService.getResourcePermissions.mockResolvedValue([
"organization.attributes.read",
"organization.attributes.create",
"organization.attributes.update",
"organization.attributes.delete",
]);
vi.mocked(PermissionMapper.toActionMap).mockReturnValue({
[CrudAction.Read]: true,
[CrudAction.Create]: true,
[CrudAction.Update]: true,
[CrudAction.Delete]: true,
} as any);
const result = await getResourcePermissions({
userId: 1,
teamId: 2,
resource: Resource.Attributes,
userRole: MembershipRole.ADMIN,
});
expect(mockPermissionCheckService.getResourcePermissions).toHaveBeenCalledWith({
userId: 1,
teamId: 2,
resource: Resource.Attributes,
});
expect(result).toEqual({
canRead: true,
canEdit: true,
canDelete: true,
canCreate: true,
});
});
});
describe("edge cases", () => {
it("should handle errors from FeaturesRepository", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockRejectedValue(new Error("Database connection failed"));
await expect(
getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.OWNER,
})
).rejects.toThrow("Database connection failed");
});
it("should handle errors from PermissionCheckService", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
mockPermissionCheckService.getResourcePermissions.mockRejectedValue(
new Error("Permission service error")
);
await expect(
getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.Team,
userRole: MembershipRole.OWNER,
})
).rejects.toThrow("Permission service error");
});
});
});
@@ -0,0 +1,86 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { MembershipRole } from "@calcom/prisma/enums";
import { PermissionMapper } from "../domain/mappers/PermissionMapper";
import type { Resource } from "../domain/types/permission-registry";
import { CrudAction } from "../domain/types/permission-registry";
import { PermissionCheckService } from "../services/permission-check.service";
interface RoleMapping {
roles: MembershipRole[];
condition?: (userRole: MembershipRole) => boolean;
}
interface FallbackRoleConfig {
read?: RoleMapping;
create?: RoleMapping;
update?: RoleMapping;
delete?: RoleMapping;
}
interface ResourcePermissionsOptions {
userId: number;
teamId: number;
resource: Resource;
userRole: MembershipRole;
fallbackRoles?: FallbackRoleConfig;
}
interface ResourcePermissions {
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canCreate: boolean;
}
const checkRoleAccess = (userRole: MembershipRole, mapping?: RoleMapping): boolean => {
if (!mapping) return false;
const { roles, condition } = mapping;
const hasRole = roles.includes(userRole);
if (condition) {
return hasRole && condition(userRole);
}
return hasRole;
};
export const getResourcePermissions = async ({
userId,
teamId,
resource,
userRole,
fallbackRoles = {},
}: ResourcePermissionsOptions): Promise<ResourcePermissions> => {
const featureRepo = new FeaturesRepository();
const permissionService = new PermissionCheckService();
const pbacEnabled = await featureRepo.checkIfTeamHasFeature(teamId, "pbac");
// If PBAC is disabled, use fallback role configuration
if (!pbacEnabled) {
return {
canRead: checkRoleAccess(userRole, fallbackRoles.read),
canCreate: checkRoleAccess(userRole, fallbackRoles.create),
canEdit: checkRoleAccess(userRole, fallbackRoles.update),
canDelete: checkRoleAccess(userRole, fallbackRoles.delete),
};
}
// PBAC is enabled, get permissions from the service
const resourcePermissions = await permissionService.getResourcePermissions({
userId,
teamId,
resource,
});
const roleActions = PermissionMapper.toActionMap(resourcePermissions, resource);
return {
canRead: roleActions[CrudAction.Read] ?? false,
canEdit: roleActions[CrudAction.Update] ?? false,
canDelete: roleActions[CrudAction.Delete] ?? false,
canCreate: roleActions[CrudAction.Create] ?? false,
};
};
@@ -0,0 +1,22 @@
-- Add attribute permissions for admin role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
-- Attribute permissions
('organization.attributes', 'create'),
('organization.attributes', 'read'),
('organization.attributes', 'update'),
('organization.attributes', 'delete')
) AS permissions(resource, action);
-- Add read permission for member role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'member_role', resource, action, NOW()
FROM (
VALUES
-- Attribute permissions - read only
('organization.attributes', 'read')
) AS permissions(resource, action);
@@ -1,7 +1,9 @@
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { Attribute } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { Prisma, MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
@@ -28,6 +30,42 @@ const createAttributesHandler = async ({ input, ctx }: GetOptions) => {
});
}
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: org.id,
},
select: {
role: true,
},
});
if (!membership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of this organization to use this feature",
});
}
const { canCreate } = await getResourcePermissions({
userId: ctx.user.id,
teamId: org.id,
resource: Resource.Attributes,
userRole: membership.role,
fallbackRoles: {
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canCreate) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to create attributes",
});
}
const slug = slugify(input.name);
const uniqueOptions = getOptionsWithValidContains(input.options);
@@ -1,4 +1,7 @@
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
@@ -22,6 +25,42 @@ const deleteAttributeHandler = async ({ input, ctx }: DeleteOptions) => {
});
}
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: org.id,
},
select: {
role: true,
},
});
if (!membership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of this organization to use this feature",
});
}
const { canDelete } = await getResourcePermissions({
userId: ctx.user.id,
teamId: org.id,
resource: Resource.Attributes,
userRole: membership.role,
fallbackRoles: {
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canDelete) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to delete attributes",
});
}
const attribute = await prisma.attribute.delete({
where: {
teamId: org.id,
@@ -1,5 +1,8 @@
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
@@ -24,6 +27,42 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
});
}
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: org.id,
},
select: {
role: true,
},
});
if (!membership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of this organization to use this feature",
});
}
const { canEdit } = await getResourcePermissions({
userId: ctx.user.id,
teamId: org.id,
resource: Resource.Attributes,
userRole: membership.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canEdit) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to edit attributes",
});
}
// If an option is removed, it is to be removed from contains of corresponding group as well if any
const options = getOptionsWithValidContains(input.options);
@@ -1,4 +1,7 @@
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
@@ -22,6 +25,42 @@ const toggleActiveHandler = async ({ input, ctx }: GetOptions) => {
});
}
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: org.id,
},
select: {
role: true,
},
});
if (!membership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of this organization to use this feature",
});
}
const { canEdit } = await getResourcePermissions({
userId: ctx.user.id,
teamId: org.id,
resource: Resource.Attributes,
userRole: membership.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canEdit) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to modify attributes",
});
}
// Ensure that this users org owns the attribute
const attribute = await prisma.attribute.findUnique({
where: {
@@ -1,13 +1,14 @@
import type { Prisma } from "@prisma/client";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import type { PrismaClient } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -109,12 +110,33 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// A user can only have one org so we pass in their currentOrgId here
const currentOrgId = ctx.user?.organization?.id || input.orgId;
const isUserOrganizationAdmin = currentOrgId && (await isOrganisationAdmin(ctx.user?.id, currentOrgId));
const isUserRoleAdmin = ctx.user.role === UserPermissionRole.ADMIN;
if (!currentOrgId) throw new TRPCError({ code: "BAD_REQUEST", message: "Organization ID is required." });
const isUserAuthorizedToUpdate = !!(isUserOrganizationAdmin || isUserRoleAdmin);
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: currentOrgId,
},
select: {
role: true,
},
});
if (!currentOrgId || !isUserAuthorizedToUpdate) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!membership) throw new TRPCError({ code: "UNAUTHORIZED", message: "User role is required." });
const { canEdit } = await getResourcePermissions({
userId: ctx.user.id,
teamId: currentOrgId,
resource: Resource.Organization,
userRole: membership.role,
fallbackRoles: {
update: {
roles: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
},
});
if (!canEdit) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.slug) {
const userConflict = await prisma.team.findMany({