Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
131fe52
PBAC on team members page + org members page to use specific permissi…
sean-brydon Aug 11, 2025
b52f355
Implement checks on edit sheet fetch to check for permisisons
sean-brydon Aug 11, 2025
8ead04b
WIP permission fixes
sean-brydon Aug 11, 2025
447ce58
fix tests
sean-brydon Aug 12, 2025
f4413b4
WIP
sean-brydon Aug 12, 2025
36de220
fix edit mode perms
sean-brydon Aug 12, 2025
da52e6e
fix remove and list view permissions
sean-brydon Aug 12, 2025
cf92565
fix add
sean-brydon Aug 12, 2025
868e104
Merge branch 'main' into feat/pbac-team-members
sean-brydon Aug 12, 2025
1c43f49
feat: add pbac permissions for impersonation (#23035)
sean-brydon Aug 26, 2025
31ec854
Merge branch 'main' into feat/pbac-team-members
volnei Aug 27, 2025
9327a30
Merge branch 'main' into feat/pbac-team-members
eunjae-lee Aug 28, 2025
cb1bd94
add missing prisma import
eunjae-lee Aug 28, 2025
51802af
add resend invitation and edit mode checks for delete/changeRole
sean-brydon Aug 29, 2025
983bfe6
fix canChangeRole
sean-brydon Aug 29, 2025
3bd186d
Merge branch 'main' into feat/pbac-team-members
sean-brydon Aug 29, 2025
adc31bd
Merge branch 'main' into feat/pbac-team-members
sean-brydon Aug 29, 2025
789ff27
canChangeRole gate
sean-brydon Aug 29, 2025
cf6f0cd
fix permission logic
sean-brydon Aug 29, 2025
c357aa4
fix depends on
sean-brydon Aug 29, 2025
7c7df00
push migration
sean-brydon Aug 29, 2025
9ba7f2b
fix migration
sean-brydon Aug 29, 2025
94b2a33
Merge branch 'main' into feat/pbac-team-members
sean-brydon Aug 29, 2025
9381049
fix scoped advanced permissions
sean-brydon Sep 1, 2025
2fff396
use listMembers instead of invite proxy
sean-brydon Sep 1, 2025
eb60fb8
remove isOrgAdmin check to rely on pbac
sean-brydon Sep 1, 2025
f829002
wait for session to load before navigating
sean-brydon Sep 1, 2025
f701ceb
use event-types testId to ensure session loaded
sean-brydon Sep 1, 2025
e57e998
Merge branch 'main' into fix/api-login-diff-session
sean-brydon Sep 1, 2025
a327df0
use profile page instead of eventTypes
sean-brydon Sep 1, 2025
e4d4fe7
Merge remote-tracking branch 'refs/remotes/origin/fix/api-login-diff-…
sean-brydon Sep 1, 2025
4976ce5
Merge branch 'fix/api-login-diff-session' into feat/pbac-team-members
sean-brydon Sep 1, 2025
84dd982
Merge branch 'main' into feat/pbac-team-members
sean-brydon Sep 2, 2025
fdaa107
use correct roles
sean-brydon Sep 2, 2025
1bea772
fix wait for session
sean-brydon Sep 2, 2025
c16462d
correct orgs private isFallBackRoles
sean-brydon Sep 2, 2025
dcce84b
set a timeout on waiting for session
sean-brydon Sep 2, 2025
8e3ef83
remove adminOrOwner check since its in fallpack
sean-brydon Sep 2, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,18 @@ const organizationAdminKeys = [
"delegation_credential",
];

export interface SettingsPermissions {
canViewRoles?: boolean;
}

const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
permissions,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
Expand Down Expand Up @@ -227,7 +231,7 @@ const useTabs = ({

// 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) {
if (isPbacEnabled && permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
Expand Down Expand Up @@ -294,7 +298,7 @@ interface SettingsSidebarContainerProps {
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
}

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

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

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

const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
teamFeatures,
canViewRoles,
permissions,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
Expand All @@ -881,7 +885,7 @@ const SidebarContainerElement = ({
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
permissions={permissions}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {

return (
<>
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles }}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import { useState } from "react";

import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import {
Scope,
CrudAction,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Checkbox, Label } from "@calcom/ui/components/form";
Expand All @@ -17,6 +21,7 @@ interface AdvancedPermissionGroupProps {
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
}

const INTERNAL_DATAACCESS_KEY = "_resource";
Expand All @@ -26,21 +31,31 @@ export function AdvancedPermissionGroup({
selectedPermissions,
onChange,
disabled,
scope = Scope.Organization,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
const resourceConfig = PERMISSION_REGISTRY[resource];
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);

const isAllResources = resource === "*";

// Early return if resource is not in the scoped registry (and not the special "*" resource)
if (!isAllResources && !resourceConfig) {
return null;
}

const allResourcesSelected = selectedPermissions.includes("*.*");

// Get all possible permissions for this resource
const allPermissions = isAllResources
? ["*.*"]
: Object.entries(resourceConfig)
: resourceConfig
? Object.entries(resourceConfig)
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
.map(([action]) => `${resource}.${action}`);
.map(([action]) => `${resource}.${action}`)
: [];

// Check if all permissions for this resource are selected
const isAllSelected = isAllResources
Expand Down Expand Up @@ -99,7 +114,7 @@ export function AdvancedPermissionGroup({
<span
className="text-default cursor-pointer text-sm font-medium leading-none"
onClick={() => setIsExpanded(!isExpanded)}>
{t(resourceConfig._resource?.i18nKey || "")}
{t(resourceConfig?._resource?.i18nKey || "")}
</span>
<span
className="text-muted cursor-pointer text-sm font-medium leading-none"
Expand All @@ -113,56 +128,57 @@ export function AdvancedPermissionGroup({
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
>
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;

if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}

const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);

return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
{resourceConfig &&
Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;

if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}

const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);

return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
)}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</span>
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</div>
</div>
</div>
);
})}{" "}
);
})}{" "}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
});

const onSubmit = (values: FormValues) => {
// Store the color in localStorage
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
localStorage.setItem(`role_color_${roleKey}`, values.color);

if (isEditing && role) {
updateMutation.mutate({
teamId,
Expand Down Expand Up @@ -224,6 +220,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
/>
))}
</div>
Expand All @@ -247,6 +244,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
Expand All @@ -13,16 +13,18 @@ interface SimplePermissionItemProps {
permissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
}

export function SimplePermissionItem({
resource,
permissions,
onChange,
disabled,
scope,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);

Comment on lines +24 to 28
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Ensure getResourcePermissionLevel is scope-aware.

This component calls usePermissions(scope), but the hook’s getResourcePermissionLevel currently reads from the global registry (see usePermissions.ts). Fix there to avoid mismatched “all/read” states when actions are out of scope.


🏁 Script executed:

#!/bin/bash
# locate the file defining usePermissions hook
file=$(rg -l "export function usePermissions" -g "*.ts" -g "*.tsx" | head -n1)
echo "usePermissions implementation at: $file"
sed -n '1,200p' "$file"

Length of output: 1272


🏁 Script executed:

#!/bin/bash
# Locate the SimplePermissionItem component file
file=$(rg -l "SimplePermissionItem.tsx" -g "*.tsx" | head -n1)
echo "Component file: $file"
# Print top of file to see imports and hook usage
sed -n '1,200p' "$file"

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Locate SimplePermissionItem.tsx in the web app
echo "Searching for SimplePermissionItem.tsx under apps/web:"
find apps/web -type f -name "SimplePermissionItem.tsx"

# If found, print the first 200 lines of the file
file=$(find apps/web -type f -name "SimplePermissionItem.tsx" | head -n1)
if [[ -n "$file" ]]; then
  echo "Component file found at: $file"
  sed -n '1,200p' "$file"
else
  echo "Component file not found"
fi

Length of output: 2816


🏁 Script executed:

# Locate usePermissions hook file(s) under apps/web/app
echo "Searching for usePermissions.ts in web app..."
find apps/web/app -type f -name "usePermissions.ts"

# For each found file, print its top 200 lines
for f in $(find apps/web/app -type f -name "usePermissions.ts"); do
  echo "=== $f ==="
  sed -n '1,200p' "$f"
done

Length of output: 6800


Make getResourcePermissionLevel scope-aware in usePermissions.ts
In apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts, update getResourcePermissionLevel to pull its resourceConfig from getPermissionsForScope(scope) instead of the global PERMISSION_REGISTRY so it only considers actions valid for the given scope.

🤖 Prompt for AI Agents
In
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts
around the getResourcePermissionLevel implementation, update the function to
retrieve resourceConfig from getPermissionsForScope(scope) instead of using the
global PERMISSION_REGISTRY so it only considers actions valid for the provided
scope; ensure the function accepts/receives the scope parameter and uses
getPermissionsForScope(scope).resourceConfig (or equivalent shape) when looking
up the resource and its actions, and adjust any call sites (e.g.,
SimplePermissionItem) to pass the scope through if necessary so permission
checks are scope-aware.

const isAllResources = resource === "*";
const options = isAllResources
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import {
PERMISSION_REGISTRY,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import {
getTransitiveDependencies,
getTransitiveDependents,
Expand All @@ -18,10 +21,11 @@ interface UsePermissionsReturn {
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
}

export function usePermissions(): UsePermissionsReturn {
export function usePermissions(scope: Scope = Scope.Organization): UsePermissionsReturn {
const getAllPossiblePermissions = () => {
const permissions: string[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
const scopedRegistry = getPermissionsForScope(scope);
Object.entries(scopedRegistry).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config)
.filter((action) => !action.startsWith("_"))
Expand All @@ -34,7 +38,8 @@ export function usePermissions(): UsePermissionsReturn {
};

const hasAllPermissions = (permissions: string[]) => {
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
const scopedRegistry = getPermissionsForScope(scope);
return Object.entries(scopedRegistry).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config)
.filter((action) => !action.startsWith("_"))
Expand Down Expand Up @@ -85,7 +90,8 @@ export function usePermissions(): UsePermissionsReturn {
} else {
// Filter out current resource permissions
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
const scopedRegistry = getPermissionsForScope(scope);
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];

if (!resourceConfig) return currentPermissions;

Expand Down
Loading
Loading