feat: enable PBAC checking on organization settings page (#22467)
This commit is contained in:
+39
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+34
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+30
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+43
-1
@@ -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} />}
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
+63
-46
@@ -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,
|
||||
};
|
||||
};
|
||||
+22
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user