diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 161b5a81a0..6f5abdb327 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -293,7 +293,36 @@ interface SettingsSidebarContainerProps { teamFeatures?: Record; } -const TeamListCollapsible = () => { +const TeamRolesNavItem = ({ + team, + teamFeatures, +}: { + team: { id: number; parentId?: number | null }; + teamFeatures?: Record; +}) => { + const { t } = useLocale(); + + // Always call the hook first (Rules of Hooks) + const isPbacEnabled = useIsFeatureEnabledForTeam({ + teamFeatures, + teamId: team.parentId || 0, // Use 0 as fallback when no parentId + feature: "pbac", + }); + + // Only show for sub-teams (teams with parentId) AND when parent has PBAC enabled + if (!team.parentId || !isPbacEnabled) return null; + + return ( + + ); +}; + +const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record }) => { const { data: teams } = trpc.viewer.teams.list.useQuery(); const { t } = useLocale(); const [teamMenuState, setTeamMenuState] = @@ -402,6 +431,8 @@ const TeamListCollapsible = () => { textClassNames="px-3 text-emphasis font-medium text-sm" disableChevron /> + {/* Show roles only for sub-teams with PBAC-enabled parent */} + {(checkAdminOrOwner(team.role) || // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this exists wtf? @@ -588,7 +619,7 @@ const SettingsSidebarContainer = ({ - + {(!orgBranding?.id || isOrgAdminOrOwner) && ( { showToast(t("role_deleted_successfully"), "success"); setIsModalOpen(false); - await revalidateTeamRoles(); + await revalidateTeamRoles(teamId); router.refresh(); onDeleted?.(); }, diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx index 59f10c7a1a..17ad838ba7 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx @@ -7,7 +7,11 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; -import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry"; +import { + CrudAction, + Scope, + getPermissionsForScope, +} from "@calcom/features/pbac/domain/types/permission-registry"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/button"; @@ -51,9 +55,10 @@ interface RoleSheetProps { open: boolean; onOpenChange: (open: boolean) => void; teamId: number; + scope?: Scope; } -export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) { +export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) { const { t } = useLocale(); const router = useRouter(); const isEditing = Boolean(role); @@ -102,23 +107,23 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) const { isAdvancedMode, permissions, color } = form.watch(); const filteredResources = useMemo(() => { - return Object.keys(PERMISSION_REGISTRY).filter((resource) => + const scopedRegistry = getPermissionsForScope(scope); + return Object.keys(scopedRegistry).filter((resource) => t( - PERMISSION_REGISTRY[resource as Resource][ - CrudAction.All as keyof (typeof PERMISSION_REGISTRY)[Resource] - ]?.i18nKey || "" + scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]] + ?.i18nKey || "" ) .toLowerCase() .includes(searchQuery.toLowerCase()) ); - }, [searchQuery, t]); + }, [searchQuery, t, scope]); const createMutation = trpc.viewer.pbac.createRole.useMutation({ onSuccess: async () => { showToast(t("role_created_successfully"), "success"); form.reset(); onOpenChange(false); - await revalidateTeamRoles(); + await revalidateTeamRoles(teamId); router.refresh(); }, onError: (error) => { @@ -130,7 +135,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) onSuccess: async () => { showToast(t("role_updated_successfully"), "success"); onOpenChange(false); - await revalidateTeamRoles(); + await revalidateTeamRoles(teamId); router.refresh(); }, onError: (error) => { @@ -228,7 +233,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
- {Object.keys(PERMISSION_REGISTRY).map((resource) => ( + {Object.keys(getPermissionsForScope(scope)).map((resource) => ( ); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/actions.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/actions.ts index d99219f210..24d05a6286 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/actions.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/actions.ts @@ -1,7 +1,17 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePath, revalidateTag } from "next/cache"; -export async function revalidateTeamRoles() { +export async function revalidateTeamRoles(teamId: number) { + // Revalidate organization roles path revalidatePath("/settings/organizations/roles"); + + // Revalidate team roles paths (dynamic routes) + revalidatePath("/settings/teams/[id]/roles", "page"); + + // Invalidate team-specific cache tags + revalidateTag(`team-roles-${teamId}`); + revalidateTag(`resource-permissions-${teamId}`); + revalidateTag(`team-members-${teamId}`); + revalidateTag(`team-features-${teamId}`); } diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx new file mode 100644 index 0000000000..456b180c86 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx @@ -0,0 +1,160 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; +import { unstable_cache } from "next/cache"; +import { cookies, headers } from "next/headers"; +import { notFound } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import type { AppFlags } 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, Scope } from "@calcom/features/pbac/domain/types/permission-registry"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { RoleService } from "@calcom/features/pbac/services/role.service"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import { CreateRoleCTA } from "../../../organizations/roles/_components/CreateRoleCta"; +import { RolesList } from "../../../organizations/roles/_components/RolesList"; +import { roleSearchParamsCache } from "../../../organizations/roles/_components/searchParams"; + +const getCachedTeamRoles = (teamId: number) => + unstable_cache( + async () => { + const roleService = new RoleService(); + return roleService.getTeamRoles(teamId); + }, + [`team-roles-for-team-${teamId}`], + { revalidate: 3600, tags: [`team-roles-${teamId}`] } + ); + +const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) => + unstable_cache( + async () => { + const featureRepo = new FeaturesRepository(); + const res = await featureRepo.checkIfTeamHasFeature(teamId, feature); + return res; + }, + [`team-feature-for-roles-${teamId}-${feature}`], + { revalidate: 3600, tags: [`team-features-${teamId}`] } + ); + +const getCachedResourcePermissions = (userId: number, teamId: number, resource: Resource) => + unstable_cache( + async () => { + const permissionService = new PermissionCheckService(); + return permissionService.getResourcePermissions({ userId, teamId, resource }); + }, + [`resource-permissions-for-team-roles-${userId}-${teamId}-${resource}`], + { revalidate: 3600, tags: [`resource-permissions-${teamId}`] } + ); + +const getCachedTeam = (teamId: string, userId: number) => + unstable_cache( + async () => { + return getTeamWithMembers({ + id: Number(teamId), + userId: userId, + isTeamView: true, + }); + }, + [`team-with-members-for-roles-${teamId}-${userId}`], + { revalidate: 3600, tags: [`team-members-${teamId}`] } + ); + +export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + return await _generateMetadata( + (t) => t("roles_and_permissions"), + (t) => t("roles_and_permissions_description"), + undefined, + undefined, + `/settings/teams/${id}/roles` + ); +}; + +const Page = async ({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Record; +}) => { + const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const { id: teamId } = await params; + + if (!session?.user?.id) { + return notFound(); + } + + // Get team information including parent + const team = await getCachedTeam(teamId, session.user.id)(); + + if (!team) { + return notFound(); + } + + // Check if team has a parent (is a sub-team) + if (!team.parent?.id) { + return notFound(); + } + + // Check if parent team has PBAC feature enabled + const parentTeamHasPBACFeature = await getCachedTeamFeature(team.parent.id, "pbac")(); + + if (!parentTeamHasPBACFeature) { + return notFound(); + } + + roleSearchParamsCache.parse(searchParams); + + const [roles, rolePermissions] = await Promise.all([ + getCachedTeamRoles(team.id)(), + getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(), + ]); + + // NOTE: this approach of fetching permssions per resource does not account for fall back roles. + const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role); + + const canCreate = roleActions[CrudAction.Create] ?? false; + const canRead = roleActions[CrudAction.Read] ?? false; + const canUpdate = roleActions[CrudAction.Update] ?? false; + const canDelete = roleActions[CrudAction.Delete] ?? false; + + if (!canRead) { + return notFound(); + } + + // Get sheet state from server cache + const isSheetOpen = roleSearchParamsCache.get("role-sheet"); + const selectedRoleId = roleSearchParamsCache.get("role"); + const selectedRole = roles.find((role) => role.id === selectedRoleId); + + // Use Team scope for team roles (this will automatically filter appropriate resources) + + return ( + : null}> + + + ); +}; + +export default Page; diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 7bce9e89de..689364d9e5 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -27,11 +27,17 @@ export enum CustomAction { ReadRecordings = "readRecordings", } +export enum Scope { + Team = "team", + Organization = "organization", +} + export interface PermissionDetails { description: string; category: string; i18nKey: string; descriptionI18nKey: string; + scope?: Scope[]; // Optional for backward compatibility } export type ResourceConfig = { @@ -58,6 +64,38 @@ export const filterResourceConfig = (config: ResourceConfig): Omit { + const filteredRegistry: Partial = {}; + + Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => { + const filteredConfig: ResourceConfig = { _resource: config._resource }; + + Object.entries(config).forEach(([action, details]) => { + if (action === "_resource") return; + + const permissionDetails = details as PermissionDetails; + // If no scope is defined, include in both Team and Organization (backward compatibility) + // If scope is defined, only include if it matches the requested scope + if (!permissionDetails.scope || permissionDetails.scope.includes(scope)) { + filteredConfig[action as CrudAction | CustomAction] = permissionDetails; + } + }); + + // Only include resource if it has at least one action for this scope + const hasActions = Object.keys(filteredConfig).length > 1; // > 1 because _resource is always there + if (hasActions) { + filteredRegistry[resource as Resource] = filteredConfig; + } + }); + + return filteredRegistry as PermissionRegistry; +}; + // Keep in mind these are on a team/organization level, not a user level export const PERMISSION_REGISTRY: PermissionRegistry = { [Resource.All]: { @@ -69,6 +107,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "system", i18nKey: "pbac_resource_all", descriptionI18nKey: "pbac_desc_all_actions_all_resources", + scope: [Scope.Organization], // Only organizations should have "All" permissions }, }, [Resource.Role]: { @@ -138,6 +177,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "team", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_teams", + scope: [Scope.Organization], }, [CrudAction.Read]: { description: "View team details", @@ -185,48 +225,56 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "org", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_organization", + scope: [Scope.Organization], }, [CrudAction.Read]: { description: "View organization details", category: "org", i18nKey: "pbac_action_read", descriptionI18nKey: "pbac_desc_view_organization_details", + scope: [Scope.Organization], }, [CustomAction.ListMembers]: { description: "List organization members", category: "org", i18nKey: "pbac_action_list_members", descriptionI18nKey: "pbac_desc_list_organization_members", + scope: [Scope.Organization], }, [CustomAction.Invite]: { description: "Invite organization members", category: "org", i18nKey: "pbac_action_invite", descriptionI18nKey: "pbac_desc_invite_organization_members", + scope: [Scope.Organization], }, [CustomAction.Remove]: { description: "Remove organization members", category: "org", i18nKey: "pbac_action_remove", descriptionI18nKey: "pbac_desc_remove_organization_members", + scope: [Scope.Organization], }, [CustomAction.ManageBilling]: { description: "Manage organization billing", category: "org", i18nKey: "pbac_action_manage_billing", descriptionI18nKey: "pbac_desc_manage_organization_billing", + scope: [Scope.Organization], }, [CustomAction.ChangeMemberRole]: { description: "Change role of team members", category: "org", i18nKey: "pbac_action_change_member_role", descriptionI18nKey: "pbac_desc_change_organization_member_role", + scope: [Scope.Organization], }, [CrudAction.Update]: { description: "Edit organization settings", category: "org", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_edit_organization_settings", + scope: [Scope.Organization], }, }, [Resource.Booking]: { @@ -244,12 +292,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "booking", i18nKey: "pbac_action_read_team_bookings", descriptionI18nKey: "pbac_desc_view_team_bookings", + scope: [Scope.Team], }, [CustomAction.ReadOrgBookings]: { description: "View organization bookings", category: "booking", i18nKey: "pbac_action_read_org_bookings", descriptionI18nKey: "pbac_desc_view_organization_bookings", + scope: [Scope.Organization], }, [CustomAction.ReadRecordings]: { description: "View booking recordings", diff --git a/packages/features/pbac/infrastructure/repositories/RoleRepository.ts b/packages/features/pbac/infrastructure/repositories/RoleRepository.ts index f37037956c..c0c6a0f008 100644 --- a/packages/features/pbac/infrastructure/repositories/RoleRepository.ts +++ b/packages/features/pbac/infrastructure/repositories/RoleRepository.ts @@ -20,7 +20,12 @@ export class RoleRepository { async findByTeamId(teamId: number): Promise { const roles = await this.client.role.findMany({ - where: { teamId }, + where: { + OR: [ + { teamId }, // Team-specific custom roles + { teamId: null, type: "SYSTEM" }, // Default/system roles available to all teams + ], + }, include: { permissions: true }, }); return RoleOutputMapper.toDomainList(roles);