Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cursor/rules/review.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@
- "summary" fields do not end with a period
- "summary" fields are written in proper American english
- When changes to API v2 or v1 are made, ensure there are no breaking changes on existing endpoints. Instead, there needs to be a newly versioned endpoint with the updated functionality but the old one must remain functional
- For large pull requests (>500 lines changed or >10 files touched), advise splitting into smaller, focused PRs:
- Split by feature boundaries: separate different features or user stories
- Split by layer/component: frontend changes, backend changes, database migrations, and tests in separate PRs
- Split by dependency chain: create PRs that can be merged sequentially
- Split by file/module: group related file changes together
- Suggested pattern: Database migrations → Backend logic → Frontend components → Integration tests
- Benefits: easier review, faster feedback, reduced conflicts, easier rollback, better history
Empty file added .yarn/versions/3d3aec2b.yml
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { unstable_cache } from "next/cache";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/team";
import { TeamService } from "@calcom/lib/server/service/teamService";
import prisma from "@calcom/prisma";

import { TRPCError } from "@trpc/server";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ const organizationAdminKeys = [
const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
canViewRoles?: boolean;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
Expand Down Expand Up @@ -223,8 +225,9 @@ const useTabs = ({
});
}

// Add pbac menu item only if feature flag is enabled
if (isPbacEnabled) {
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
if (isPbacEnabled && canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
Expand Down Expand Up @@ -291,6 +294,7 @@ interface SettingsSidebarContainerProps {
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
}

const TeamRolesNavItem = ({
Expand Down Expand Up @@ -483,6 +487,7 @@ const SettingsSidebarContainer = ({
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
canViewRoles,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
Expand Down Expand Up @@ -512,6 +517,7 @@ const SettingsSidebarContainer = ({
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
});

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

export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
export default function SettingsLayoutAppDirClient({
children,
teamFeatures,
canViewRoles,
...rest
}: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
const [sideContainerOpen, setSideContainerOpen] = state;
Expand Down Expand Up @@ -821,6 +833,7 @@ export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
/>
}
drawerState={state}
Expand All @@ -843,13 +856,15 @@ type SidebarContainerElementProps = {
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
};

const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
teamFeatures,
canViewRoles,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
Expand All @@ -866,6 +881,7 @@ const SidebarContainerElement = ({
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import React from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";

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

Expand All @@ -23,6 +26,15 @@ const getTeamFeatures = unstable_cache(
}
);

const getCachedResourcePermissions = unstable_cache(
async (userId: number, teamId: number, resource: Resource) => {
const permissionService = new PermissionCheckService();
return permissionService.getResourcePermissions({ userId, teamId, resource });
},
["resource-permissions"],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The cache key ["resource-permissions"] is too generic and could lead to cache collisions. Consider including userId, teamId, and resource in the cache key for better cache isolation.

Suggested change
["resource-permissions"],
[`resource-permissions-${userId}-${teamId}-${resource}`],

{ revalidate: 120 }
);

export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id;
Expand All @@ -31,20 +43,30 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
}

let teamFeatures: Record<number, TeamFeatures> | null = null;
let canViewRoles = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;

// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const features = await getTeamFeatures(orgId);
const [features, rolePermissions] = await Promise.all([
getTeamFeatures(orgId),
getCachedResourcePermissions(userId, orgId, Resource.Role),
]);

if (features) {
teamFeatures = {
[orgId]: features,
};

// Check if user has permission to read roles
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
canViewRoles = roleActions[CrudAction.Read] ?? false;
Comment on lines +62 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The permission check logic is nested inside the if (features) block, which means permissions won't be checked if team features are unavailable. This could be a security issue if features and permissions are independent.

}
}

return (
<>
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export function AdvancedPermissionGroup({
}
};

const handleCheckedChange = (checked: boolean | string) => {
if (!disabled) {
onChange(toggleResourcePermissionLevel(resource, checked ? "all" : "none", selectedPermissions));
}
};

// Helper function to check if read permission is auto-enabled
const isReadAutoEnabled = (action: string) => {
if (action === CrudAction.Read) return false;
Expand All @@ -68,25 +74,38 @@ export function AdvancedPermissionGroup({
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
onClick={() => setIsExpanded(!isExpanded)}>
<Icon
name={isAllResources ? "chevron-right" : "chevron-down"}
className={classNames(
"h-4 w-4 transition-transform",
isExpanded && !isAllResources ? "rotate-180" : ""
)}
/>
onClick={(e) => {
// Only toggle expansion if clicking on the button itself, not child elements
if (e.target === e.currentTarget) {
setIsExpanded(!isExpanded);
}
Comment on lines +78 to +81
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The condition e.target === e.currentTarget will only be true if clicking directly on the button element, but the button contains child elements. Users clicking on empty space within the button won't trigger expansion.

}}>
<div className="flex items-center gap-1.5" onClick={() => setIsExpanded(!isExpanded)}>
<Icon
name="chevron-right"
className={classNames(
"h-4 w-4 transition-transform",
isExpanded && !isAllResources ? "rotate-90" : ""
)}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={isAllSelected}
onCheckedChange={() => handleToggleAll}
onCheckedChange={handleCheckedChange}
onClick={handleToggleAll}
disabled={disabled}
Comment on lines 93 to 97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Checkbox has both onCheckedChange and onClick handlers that perform the same operation. This could lead to the toggle logic being called twice when the checkbox is clicked.

Suggested change
<Checkbox
checked={isAllSelected}
onCheckedChange={() => handleToggleAll}
onCheckedChange={handleCheckedChange}
onClick={handleToggleAll}
disabled={disabled}
<Checkbox
checked={isAllSelected}
onCheckedChange={handleCheckedChange}
disabled={disabled}

/>
<span className="text-default text-sm font-medium leading-none">
<span
className="text-default cursor-pointer text-sm font-medium leading-none"
onClick={() => setIsExpanded(!isExpanded)}>
{t(resourceConfig._resource?.i18nKey || "")}
</span>
<span className="text-muted text-sm font-medium leading-none">{t("all_permissions")}</span>
<span
className="text-muted cursor-pointer text-sm font-medium leading-none"
onClick={() => setIsExpanded(!isExpanded)}>
{t("all_permissions")}
</span>
</div>
</button>
{isExpanded && !isAllResources && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";

import { usePermissions } from "../usePermissions";

describe("usePermissions", () => {
const { getResourcePermissionLevel } = usePermissions();

describe("getResourcePermissionLevel", () => {
it("should return 'all' for any resource when *.* permission is present", () => {
const permissions = ["*.*", "eventType.create", "eventType.read"];

expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
expect(getResourcePermissionLevel("booking", permissions)).toBe("all");
expect(getResourcePermissionLevel("team", permissions)).toBe("all");
});

it("should return 'all' for resource with all individual permissions", () => {
const permissions = ["eventType.create", "eventType.read", "eventType.update", "eventType.delete"];

expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'read' for resource with only read permission", () => {
const permissions = ["eventType.read"];

expect(getResourcePermissionLevel("eventType", permissions)).toBe("read");
});

it("should return 'none' for resource with no permissions", () => {
const permissions = ["booking.create"];

expect(getResourcePermissionLevel("eventType", permissions)).toBe("none");
});

it("should handle * resource correctly", () => {
const permissionsWithAll = ["*.*"];
const permissionsWithoutAll = ["eventType.read"];

expect(getResourcePermissionLevel("*", permissionsWithAll)).toBe("all");
expect(getResourcePermissionLevel("*", permissionsWithoutAll)).toBe("none");
});

it("should prioritize *.* over individual permissions", () => {
const permissions = ["*.*", "eventType.read"]; // Has global all but only read for eventType individually

expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ export function usePermissions(): UsePermissionsReturn {
const permissions: string[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config).forEach((action) => {
permissions.push(`${resource}.${action}`);
});
Object.keys(config)
.filter((action) => !action.startsWith("_"))
.forEach((action) => {
permissions.push(`${resource}.${action}`);
});
}
});
return permissions;
Expand All @@ -30,7 +32,9 @@ export function usePermissions(): UsePermissionsReturn {
const hasAllPermissions = (permissions: string[]) => {
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config).every((action) => permissions.includes(`${resource}.${action}`));
return Object.keys(config)
.filter((action) => !action.startsWith("_"))
.every((action) => permissions.includes(`${resource}.${action}`));
});
};

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

const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
// Check if global all permissions (*.*) is present
if (permissions.includes("*.*")) {
return "all";
}

// Filter out internal keys like _resource when checking permissions
const allResourcePerms = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_"))
.map((action) => `${resource}.${action}`);
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);

Expand Down Expand Up @@ -73,6 +85,9 @@ export function usePermissions(): UsePermissionsReturn {

if (!resourceConfig) return currentPermissions;

// Declare variable before switch to avoid scope issues
let allResourcePerms: string[];

switch (level) {
case "none":
// No permissions to add, just keep other permissions
Expand All @@ -82,8 +97,10 @@ export function usePermissions(): UsePermissionsReturn {
newPermissions.push(`${resource}.${CrudAction.Read}`);
break;
case "all":
// Add all permissions for this resource
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
// Add all permissions for this resource (excluding internal keys)
allResourcePerms = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_"))
.map((action) => `${resource}.${action}`);
newPermissions.push(...allResourcePerms);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ export async function revalidateTeamRoles(teamId: number) {
// Revalidate team roles paths (dynamic routes)
revalidatePath("/settings/teams/[id]/roles", "page");

// Invalidate team-specific cache tags
// Invalidate cache tags that match the unstable_cache keys
revalidateTag("team-roles");
revalidateTag("resource-permissions");
revalidateTag("team-feature");

// Also invalidate team-specific cache tags for completeness
revalidateTag(`team-roles-${teamId}`);
revalidateTag(`resource-permissions-${teamId}`);
revalidateTag(`team-members-${teamId}`);
Expand Down
Loading