feat: team roles ui pbac (#22585)
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
co-authored by
Eunjae Lee
parent
c8de8ca681
commit
cf392562da
+33
-2
@@ -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")}
|
||||
|
||||
+1
-1
@@ -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?.();
|
||||
},
|
||||
|
||||
+15
-10
@@ -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}
|
||||
|
||||
+4
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+12
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user