Skip to content

Commit 34b62f7

Browse files
feat: implement generalized navigation permission system with PBAC (calcom#23706)
* feat: implement generalized navigation permission system with PBAC - Create NavigationPermissionsProvider context for server-side permission data - Add checkNavigationPermissions utility using PermissionCheckService - Update main navigation layout to check permissions server-side - Enhance useShouldDisplayNavigationItem for generalized permission filtering - Support permission mapping for insights, workflows, routing, teams, members - Use unstable_cache for performance optimization - Replace insights-specific logic with scalable PBAC-based system Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: correct flag check logic in useShouldDisplayNavigationItem - Only return false when flag is explicitly false - Allow navigation permission checks to run when flags are truthy - Fixes bug where truthy flags would short-circuit permission checks Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: resolve linting errors in embed-core files - Replace forbidden non-null assertion with null check in embed.ts - Replace 'any' type with 'unknown' in embed.test.ts - Replace non-null assertion with proper null check in EmbedElement.test.ts - Remove unused variable declaration These are pre-existing linting issues unrelated to navigation permissions feature. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: resolve server-client boundary issue in NavigationPermissionsProvider - Add 'use client' directive to NavigationPermissionsProvider.ts - Replace JSX syntax with React.createElement to fix TypeScript compilation - Create NavigationPermissionsWrapper.tsx as client component bridge - Update layout.tsx to use wrapper instead of provider directly - Ensure proper separation between server-side permission checking and client-side context consumption This fixes the architectural issue where useContext was being used in server components. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * Revert "fix: resolve linting errors in embed-core files" This reverts commit 4f481ba. * refactor: simplify NavigationPermissionsProvider approach - Remove NavigationPermissionsWrapper.tsx (revert to simpler approach) - Rename NavigationPermissionsProvider.ts to .tsx for JSX support - Update layout.tsx to use NavigationPermissionsProvider directly - Keep 'use client' directive for proper server-client boundary handling - Maintain all navigation permission functionality with cleaner architecture Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up * return true for no team situation * fix: prevent hidden navigation items from reappearing via stored expansion state - Modified usePersistedExpansionState hook to accept shouldDisplay parameter - Clear stored expansion state for items that shouldn't be displayed - Prevent state persistence for items without permissions - Updated both NavigationItem and MobileNavigationMoreItem components - Use safe sessionStorage import from @calcom/lib/webstorage Fixes bug where clicking 'Workflows' would restore 'Insights' menu visibility even when user lacks insights permissions due to sessionStorage state restoration. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add sessionStorage to webstorage module and export NavigationItemName type - Added sessionStorage wrapper to @calcom/lib/webstorage with safe error handling - Export NavigationItemName type from NavigationPermissionsProvider for proper type access - Resolves TypeScript compilation errors in navigation permission system These changes enable the collapsible menu state bug fix to compile properly. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: improve separation of concerns in usePersistedExpansionState hook - Remove shouldDisplay parameter from usePersistedExpansionState hook - Move state clearing logic for hidden items to NavigationItem component level - Add setIsExpanded to useEffect dependency arrays to fix ESLint warnings - Maintain bug fix functionality while improving code design - Better separation between expansion state management and display permissions This addresses user feedback about inappropriate coupling between concerns. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix provider * fix unstable_cache * refactor: move navigation permission checks to client-side with tRPC - Replace server-side unstable_cache permission checks with client-side tRPC query - Add getNavigationPermissions query to PBAC router - Update NavigationPermissionsProvider to use tRPC with loading states - Remove server-side checkNavigationPermissions function - Improve initial page load performance by deferring permission checks Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: centralize navigation item definitions to eliminate repetition - Create NAVIGATION_ITEMS_CONFIG as single source of truth for navigation items - Auto-generate NAVIGATION_PERMISSION_MAP from centralized config - Update Navigation.tsx to use centralized definitions for teams, routing, workflows, insights - Fix import issues in NavigationPermissionsProvider.tsx - Eliminate hardcoded repetition of navigation item properties across codebase Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up * clean up permissions --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent da18630 commit 34b62f7

8 files changed

Lines changed: 170 additions & 4 deletions

File tree

apps/web/app/(use-page-wrapper)/layout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { headers } from "next/headers";
22
import Script from "next/script";
33

4+
import { NavigationPermissionsProvider } from "@calcom/features/shell/permissions/NavigationPermissionsProvider";
5+
46
import PageWrapper from "@components/PageWrapperAppDir";
57

68
export default async function PageWrapperLayout({ children }: { children: React.ReactNode }) {
@@ -21,7 +23,7 @@ export default async function PageWrapperLayout({ children }: { children: React.
2123
].filter((script): script is { id: string; script: string } => !!script.script);
2224

2325
return (
24-
<>
26+
<NavigationPermissionsProvider>
2527
<PageWrapper requiresLicense={false} nonce={nonce}>
2628
{children}
2729
{scripts.map((script) => (
@@ -36,6 +38,6 @@ export default async function PageWrapperLayout({ children }: { children: React.
3638
/>
3739
))}
3840
</PageWrapper>
39-
</>
41+
</NavigationPermissionsProvider>
4042
);
4143
}

packages/features/shell/navigation/NavigationItem.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, { Fragment, useState, useEffect } from "react";
44

55
import { useLocale } from "@calcom/lib/hooks/useLocale";
66
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
7+
import { sessionStorage } from "@calcom/lib/webstorage";
78
import classNames from "@calcom/ui/classNames";
89
import { Badge } from "@calcom/ui/components/badge";
910
import { Icon } from "@calcom/ui/components/icon";
Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import { useFlagMap } from "@calcom/features/flags/context/provider";
22
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
33

4+
import {
5+
useNavigationPermissions,
6+
type NavigationItemName,
7+
} from "../permissions/NavigationPermissionsProvider";
48
import type { NavigationItemType } from "./NavigationItem";
59

610
export function useShouldDisplayNavigationItem(item: NavigationItemType) {
711
const flags = useFlagMap();
8-
if (isKeyInObject(item.name, flags)) return flags[item.name];
12+
const { permissions: navigationPermissions, isLoading } = useNavigationPermissions();
13+
14+
if (isKeyInObject(item.name, flags) && flags[item.name] === false) {
15+
return false;
16+
}
17+
18+
if (isLoading) {
19+
return true;
20+
}
21+
22+
if (item.name in navigationPermissions) {
23+
return navigationPermissions[item.name as NavigationItemName];
24+
}
25+
926
return true;
1027
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import React, { createContext, useContext } from "react";
4+
5+
import { trpc } from "@calcom/trpc/react";
6+
7+
import type { NavigationItemName, NavigationPermissions } from "./types";
8+
import { DEFAULT_PERMISSIONS } from "./types";
9+
10+
export type { NavigationItemName, NavigationPermissions };
11+
12+
/**
13+
* Context for navigation permissions
14+
*/
15+
const NavigationPermissionsContext = createContext<{
16+
permissions: NavigationPermissions;
17+
isLoading: boolean;
18+
} | null>(null);
19+
20+
/**
21+
* Hook to access navigation permissions from context
22+
* @returns NavigationPermissions object with boolean flags for each navigation item
23+
*/
24+
export function useNavigationPermissions(): { permissions: NavigationPermissions; isLoading: boolean } {
25+
const context = useContext(NavigationPermissionsContext);
26+
if (context === null) {
27+
return {
28+
permissions: DEFAULT_PERMISSIONS,
29+
isLoading: false,
30+
};
31+
}
32+
return context;
33+
}
34+
35+
/**
36+
* Hook to check if a specific navigation item should be displayed
37+
* @param itemName The navigation item name to check
38+
* @returns boolean indicating if the item should be displayed
39+
*/
40+
export function useNavigationPermission(itemName: NavigationItemName): boolean {
41+
const { permissions } = useNavigationPermissions();
42+
return permissions[itemName];
43+
}
44+
45+
/**
46+
* Provider component for navigation permissions
47+
* Fetches permissions client-side using tRPC
48+
*/
49+
export function NavigationPermissionsProvider({ children }: { children: React.ReactNode }) {
50+
const { data: permissions, isLoading } = trpc.viewer.pbac.getNavigationPermissions.useQuery(undefined, {
51+
staleTime: 5 * 60 * 1000,
52+
refetchOnWindowFocus: false,
53+
});
54+
55+
const contextValue = {
56+
permissions: permissions || DEFAULT_PERMISSIONS,
57+
isLoading,
58+
};
59+
60+
return (
61+
<NavigationPermissionsContext.Provider value={contextValue}>
62+
{children}
63+
</NavigationPermissionsContext.Provider>
64+
);
65+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Navigation permission mapping for menu items
3+
*/
4+
export const NAVIGATION_PERMISSION_MAP = {
5+
members: "organization.listMembers",
6+
teams: "team.read",
7+
insights: "insights.read",
8+
} as const;
9+
10+
export const DEFAULT_PERMISSIONS = Object.fromEntries(
11+
Object.keys(NAVIGATION_PERMISSION_MAP).map((key) => [key, true])
12+
) as NavigationPermissions;
13+
14+
export type NavigationItemName = keyof typeof NAVIGATION_PERMISSION_MAP;
15+
16+
/**
17+
* Navigation permissions object containing boolean flags for each navigation item
18+
*/
19+
export type NavigationPermissions = Record<NavigationItemName, boolean>;

packages/lib/server/repository/membership.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma";
3-
import { MembershipRole } from "@calcom/prisma/enums";
43
import type { Prisma, Membership, PrismaClient } from "@calcom/prisma/client";
4+
import { MembershipRole } from "@calcom/prisma/enums";
55
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
66

77
import logger from "../../logger";

packages/lib/webstorage.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,35 @@ export const localStorage = {
3434
}
3535
},
3636
};
37+
38+
export const sessionStorage = {
39+
getItem(key: string) {
40+
try {
41+
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
42+
return window.sessionStorage.getItem(key);
43+
} catch (e) {
44+
// In case storage is restricted. Possible reasons
45+
// 1. Third Party Context in Chrome Incognito mode.
46+
return null;
47+
}
48+
},
49+
setItem(key: string, value: string) {
50+
try {
51+
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
52+
window.sessionStorage.setItem(key, value);
53+
} catch (e) {
54+
// In case storage is restricted. Possible reasons
55+
// 1. Third Party Context in Chrome Incognito mode.
56+
// 2. Storage limit reached
57+
return;
58+
}
59+
},
60+
removeItem: (key: string) => {
61+
try {
62+
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
63+
window.sessionStorage.removeItem(key);
64+
} catch (e) {
65+
return;
66+
}
67+
},
68+
};

packages/trpc/server/routers/viewer/pbac/_router.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { isValidPermissionString } from "@calcom/features/pbac/domain/types/perm
55
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
66
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
77
import { RoleService } from "@calcom/features/pbac/services/role.service";
8+
import {
9+
NAVIGATION_PERMISSION_MAP,
10+
DEFAULT_PERMISSIONS,
11+
type NavigationItemName,
12+
} from "@calcom/features/shell/permissions/types";
13+
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
814
import prisma from "@calcom/prisma";
915
import { RoleType, MembershipRole } from "@calcom/prisma/enums";
1016

@@ -33,6 +39,30 @@ export const permissionsRouter = router({
3339
return await permissionCheckService.getUserPermissions(ctx.user.id);
3440
}),
3541

42+
getNavigationPermissions: authedProcedure.query(async ({ ctx }) => {
43+
if (!ctx.user?.id) {
44+
return DEFAULT_PERMISSIONS;
45+
}
46+
47+
const teamIds = await MembershipRepository.findUserTeamIds({ userId: ctx.user.id });
48+
if (teamIds.length === 0) {
49+
return DEFAULT_PERMISSIONS;
50+
}
51+
52+
const permissionService = new PermissionCheckService();
53+
const navigationItems = Object.keys(NAVIGATION_PERMISSION_MAP) as Array<NavigationItemName>;
54+
55+
const permissionChecks = await Promise.all(
56+
navigationItems.map((itemName) =>
57+
permissionService.getTeamIdsWithPermission(ctx.user.id, NAVIGATION_PERMISSION_MAP[itemName])
58+
)
59+
);
60+
61+
return Object.fromEntries(
62+
navigationItems.map((itemName, index) => [itemName, permissionChecks[index].length > 0])
63+
) as Record<NavigationItemName, boolean>;
64+
}),
65+
3666
checkPermission: authedProcedure
3767
.input(
3868
z.object({

0 commit comments

Comments
 (0)