feat: team roles ui pbac (#22585)

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
sean-brydon
2025-07-18 10:12:25 +00:00
committed by GitHub
co-authored by Eunjae Lee
parent c8de8ca681
commit cf392562da
8 changed files with 281 additions and 16 deletions
@@ -293,7 +293,36 @@ interface SettingsSidebarContainerProps {
teamFeatures?: Record<number, TeamFeatures>;
}
const TeamListCollapsible = () => {
const TeamRolesNavItem = ({
team,
teamFeatures,
}: {
team: { id: number; parentId?: number | null };
teamFeatures?: Record<number, TeamFeatures>;
}) => {
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 (
<VerticalTabItem
name={t("roles_and_permissions")}
href={`/settings/teams/${team.id}/roles`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
);
};
const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, TeamFeatures> }) => {
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 */}
<TeamRolesNavItem team={team} teamFeatures={teamFeatures} />
{(checkAdminOrOwner(team.role) ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
@@ -588,7 +619,7 @@ const SettingsSidebarContainer = ({
</Skeleton>
</div>
</Link>
<TeamListCollapsible />
<TeamListCollapsible teamFeatures={teamFeatures} />
{(!orgBranding?.id || isOrgAdminOrOwner) && (
<VerticalTabItem
name={t("add_a_team")}
@@ -28,7 +28,7 @@ export function DeleteRoleModal({ roleId, roleName, teamId, onDeleted }: DeleteR
onSuccess: async () => {
showToast(t("role_deleted_successfully"), "success");
setIsModalOpen(false);
await revalidateTeamRoles();
await revalidateTeamRoles(teamId);
router.refresh();
onDeleted?.();
},
@@ -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)
</div>
</div>
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
{Object.keys(PERMISSION_REGISTRY).map((resource) => (
{Object.keys(getPermissionsForScope(scope)).map((resource) => (
<SimplePermissionItem
key={resource}
resource={resource}
@@ -1,5 +1,6 @@
"use client";
import type { Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/badge";
import { Button } from "@calcom/ui/button";
@@ -46,6 +47,7 @@ interface RolesListProps {
initialSelectedRole?: Role;
initialSheetOpen?: boolean;
teamId: number;
scope?: Scope;
}
export function RolesList({
@@ -54,6 +56,7 @@ export function RolesList({
initialSelectedRole,
initialSheetOpen,
teamId,
scope,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
@@ -107,6 +110,7 @@ export function RolesList({
open={isOpen ?? false}
onOpenChange={handleSheetOpenChange}
teamId={teamId}
scope={scope}
/>
</>
);
@@ -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}`);
}
@@ -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<string, string | string[] | undefined>;
}) => {
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 (
<SettingsHeader
title={t("roles_and_permissions")}
description={t("roles_and_permissions_description")}
borderInShellHeader={false}
CTA={canCreate ? <CreateRoleCTA /> : null}>
<RolesList
teamId={team.id}
roles={roles}
permissions={{
canCreate: canCreate,
canRead: canRead,
canUpdate: canUpdate,
canDelete: canDelete,
}}
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
scope={Scope.Team}
/>
</SettingsHeader>
);
};
export default Page;
@@ -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<ResourceConfi
return rest;
};
/**
* Filter resources and actions based on scope
* @param scope The scope to filter by (Team or Organization)
* @returns Filtered permission registry
*/
export const getPermissionsForScope = (scope: Scope): PermissionRegistry => {
const filteredRegistry: Partial<PermissionRegistry> = {};
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",
@@ -20,7 +20,12 @@ export class RoleRepository {
async findByTeamId(teamId: number): Promise<Role[]> {
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);