Skip to content

Commit 4e1048b

Browse files
authored
fix: issues found in PBAC creation (#22659)
1 parent cb78692 commit 4e1048b

File tree

5 files changed

+98
-24
lines changed

5 files changed

+98
-24
lines changed

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,11 @@ const organizationAdminKeys = [
184184
const useTabs = ({
185185
isDelegationCredentialEnabled,
186186
isPbacEnabled,
187+
canViewRoles,
187188
}: {
188189
isDelegationCredentialEnabled: boolean;
189190
isPbacEnabled: boolean;
191+
canViewRoles?: boolean;
190192
}) => {
191193
const session = useSession();
192194
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
@@ -223,8 +225,9 @@ const useTabs = ({
223225
});
224226
}
225227

226-
// Add pbac menu item only if feature flag is enabled
227-
if (isPbacEnabled) {
228+
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
229+
// This prevents showing the menu item when user has no organization permissions
230+
if (isPbacEnabled && canViewRoles) {
228231
newArray.push({
229232
name: "roles_and_permissions",
230233
href: "/settings/organizations/roles",
@@ -291,6 +294,7 @@ interface SettingsSidebarContainerProps {
291294
navigationIsOpenedOnMobile?: boolean;
292295
bannersHeight?: number;
293296
teamFeatures?: Record<number, TeamFeatures>;
297+
canViewRoles?: boolean;
294298
}
295299

296300
const TeamRolesNavItem = ({
@@ -483,6 +487,7 @@ const SettingsSidebarContainer = ({
483487
navigationIsOpenedOnMobile,
484488
bannersHeight,
485489
teamFeatures,
490+
canViewRoles,
486491
}: SettingsSidebarContainerProps) => {
487492
const searchParams = useCompatSearchParams();
488493
const orgBranding = useOrgBranding();
@@ -512,6 +517,7 @@ const SettingsSidebarContainer = ({
512517
const tabsWithPermissions = useTabs({
513518
isDelegationCredentialEnabled,
514519
isPbacEnabled,
520+
canViewRoles,
515521
});
516522

517523
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -786,9 +792,15 @@ export type SettingsLayoutProps = {
786792
children: React.ReactNode;
787793
containerClassName?: string;
788794
teamFeatures?: Record<number, TeamFeatures>;
795+
canViewRoles?: boolean;
789796
} & ComponentProps<typeof Shell>;
790797

791-
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
798+
export default function SettingsLayoutAppDirClient({
799+
children,
800+
teamFeatures,
801+
canViewRoles,
802+
...rest
803+
}: SettingsLayoutProps) {
792804
const pathname = usePathname();
793805
const state = useState(false);
794806
const [sideContainerOpen, setSideContainerOpen] = state;
@@ -821,6 +833,7 @@ export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...
821833
sideContainerOpen={sideContainerOpen}
822834
setSideContainerOpen={setSideContainerOpen}
823835
teamFeatures={teamFeatures}
836+
canViewRoles={canViewRoles}
824837
/>
825838
}
826839
drawerState={state}
@@ -843,13 +856,15 @@ type SidebarContainerElementProps = {
843856
bannersHeight?: number;
844857
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
845858
teamFeatures?: Record<number, TeamFeatures>;
859+
canViewRoles?: boolean;
846860
};
847861

848862
const SidebarContainerElement = ({
849863
sideContainerOpen,
850864
bannersHeight,
851865
setSideContainerOpen,
852866
teamFeatures,
867+
canViewRoles,
853868
}: SidebarContainerElementProps) => {
854869
const { t } = useLocale();
855870
return (
@@ -866,6 +881,7 @@ const SidebarContainerElement = ({
866881
navigationIsOpenedOnMobile={sideContainerOpen}
867882
bannersHeight={bannersHeight}
868883
teamFeatures={teamFeatures}
884+
canViewRoles={canViewRoles}
869885
/>
870886
</>
871887
);

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import React from "react";
66
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
77
import type { TeamFeatures } from "@calcom/features/flags/config";
88
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
9+
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
10+
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
11+
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
912

1013
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
1114

@@ -23,6 +26,15 @@ const getTeamFeatures = unstable_cache(
2326
}
2427
);
2528

29+
const getCachedResourcePermissions = unstable_cache(
30+
async (userId: number, teamId: number, resource: Resource) => {
31+
const permissionService = new PermissionCheckService();
32+
return permissionService.getResourcePermissions({ userId, teamId, resource });
33+
},
34+
["resource-permissions"],
35+
{ revalidate: 120 }
36+
);
37+
2638
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
2739
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
2840
const userId = session?.user?.id;
@@ -31,20 +43,30 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
3143
}
3244

3345
let teamFeatures: Record<number, TeamFeatures> | null = null;
46+
let canViewRoles = false;
3447
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
48+
3549
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
3650
if (orgId) {
37-
const features = await getTeamFeatures(orgId);
51+
const [features, rolePermissions] = await Promise.all([
52+
getTeamFeatures(orgId),
53+
getCachedResourcePermissions(userId, orgId, Resource.Role),
54+
]);
55+
3856
if (features) {
3957
teamFeatures = {
4058
[orgId]: features,
4159
};
60+
61+
// Check if user has permission to read roles
62+
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
63+
canViewRoles = roleActions[CrudAction.Read] ?? false;
4264
}
4365
}
4466

4567
return (
4668
<>
47-
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
69+
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
4870
</>
4971
);
5072
}

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export function AdvancedPermissionGroup({
5454
}
5555
};
5656

57+
const handleCheckedChange = (checked: boolean | string) => {
58+
if (!disabled) {
59+
onChange(toggleResourcePermissionLevel(resource, checked ? "all" : "none", selectedPermissions));
60+
}
61+
};
62+
5763
// Helper function to check if read permission is auto-enabled
5864
const isReadAutoEnabled = (action: string) => {
5965
if (action === CrudAction.Read) return false;
@@ -68,25 +74,38 @@ export function AdvancedPermissionGroup({
6874
<button
6975
type="button"
7076
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
71-
onClick={() => setIsExpanded(!isExpanded)}>
72-
<Icon
73-
name={isAllResources ? "chevron-right" : "chevron-down"}
74-
className={classNames(
75-
"h-4 w-4 transition-transform",
76-
isExpanded && !isAllResources ? "rotate-180" : ""
77-
)}
78-
/>
77+
onClick={(e) => {
78+
// Only toggle expansion if clicking on the button itself, not child elements
79+
if (e.target === e.currentTarget) {
80+
setIsExpanded(!isExpanded);
81+
}
82+
}}>
83+
<div className="flex items-center gap-1.5" onClick={() => setIsExpanded(!isExpanded)}>
84+
<Icon
85+
name="chevron-right"
86+
className={classNames(
87+
"h-4 w-4 transition-transform",
88+
isExpanded && !isAllResources ? "rotate-90" : ""
89+
)}
90+
/>
91+
</div>
7992
<div className="flex items-center gap-2">
8093
<Checkbox
8194
checked={isAllSelected}
82-
onCheckedChange={() => handleToggleAll}
95+
onCheckedChange={handleCheckedChange}
8396
onClick={handleToggleAll}
8497
disabled={disabled}
8598
/>
86-
<span className="text-default text-sm font-medium leading-none">
99+
<span
100+
className="text-default cursor-pointer text-sm font-medium leading-none"
101+
onClick={() => setIsExpanded(!isExpanded)}>
87102
{t(resourceConfig._resource?.i18nKey || "")}
88103
</span>
89-
<span className="text-muted text-sm font-medium leading-none">{t("all_permissions")}</span>
104+
<span
105+
className="text-muted cursor-pointer text-sm font-medium leading-none"
106+
onClick={() => setIsExpanded(!isExpanded)}>
107+
{t("all_permissions")}
108+
</span>
90109
</div>
91110
</button>
92111
{isExpanded && !isAllResources && (

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ export function usePermissions(): UsePermissionsReturn {
1919
const permissions: string[] = [];
2020
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
2121
if (resource !== "*") {
22-
Object.keys(config).forEach((action) => {
23-
permissions.push(`${resource}.${action}`);
24-
});
22+
Object.keys(config)
23+
.filter((action) => !action.startsWith("_"))
24+
.forEach((action) => {
25+
permissions.push(`${resource}.${action}`);
26+
});
2527
}
2628
});
2729
return permissions;
@@ -30,7 +32,9 @@ export function usePermissions(): UsePermissionsReturn {
3032
const hasAllPermissions = (permissions: string[]) => {
3133
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
3234
if (resource === "*") return true;
33-
return Object.keys(config).every((action) => permissions.includes(`${resource}.${action}`));
35+
return Object.keys(config)
36+
.filter((action) => !action.startsWith("_"))
37+
.every((action) => permissions.includes(`${resource}.${action}`));
3438
});
3539
};
3640

@@ -42,7 +46,10 @@ export function usePermissions(): UsePermissionsReturn {
4246
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
4347
if (!resourceConfig) return "none";
4448

45-
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
49+
// Filter out internal keys like _resource when checking permissions
50+
const allResourcePerms = Object.keys(resourceConfig)
51+
.filter((action) => !action.startsWith("_"))
52+
.map((action) => `${resource}.${action}`);
4653
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
4754
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
4855

@@ -73,6 +80,9 @@ export function usePermissions(): UsePermissionsReturn {
7380

7481
if (!resourceConfig) return currentPermissions;
7582

83+
// Declare variable before switch to avoid scope issues
84+
let allResourcePerms: string[];
85+
7686
switch (level) {
7787
case "none":
7888
// No permissions to add, just keep other permissions
@@ -82,8 +92,10 @@ export function usePermissions(): UsePermissionsReturn {
8292
newPermissions.push(`${resource}.${CrudAction.Read}`);
8393
break;
8494
case "all":
85-
// Add all permissions for this resource
86-
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
95+
// Add all permissions for this resource (excluding internal keys)
96+
allResourcePerms = Object.keys(resourceConfig)
97+
.filter((action) => !action.startsWith("_"))
98+
.map((action) => `${resource}.${action}`);
8799
newPermissions.push(...allResourcePerms);
88100
break;
89101
}

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/actions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ export async function revalidateTeamRoles(teamId: number) {
99
// Revalidate team roles paths (dynamic routes)
1010
revalidatePath("/settings/teams/[id]/roles", "page");
1111

12-
// Invalidate team-specific cache tags
12+
// Invalidate cache tags that match the unstable_cache keys
13+
revalidateTag("team-roles");
14+
revalidateTag("resource-permissions");
15+
revalidateTag("team-feature");
16+
17+
// Also invalidate team-specific cache tags for completeness
1318
revalidateTag(`team-roles-${teamId}`);
1419
revalidateTag(`resource-permissions-${teamId}`);
1520
revalidateTag(`team-members-${teamId}`);

0 commit comments

Comments
 (0)