From f438a389b61793284f1a0a4cad9503ccd23a135a Mon Sep 17 00:00:00 2001 From: Anjali Mishra <246823347+anjalimishra-0007@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:40:56 +0530 Subject: [PATCH 1/3] feat(permissions): make permissions docs link configurable Allow configuring the permissions documentation link via PermissionProvider or as a prop on PermissionShield and PermissionSessionContext instead of hardcoding docs.meshery.io. Omit the link when not provided or when explicitly set to null. Closes #1883 Signed-off-by: Anjali Mishra <246823347+anjalimishra-0007@users.noreply.github.com> --- src/__testing__/permissionKeySet.test.tsx | 105 +++++++++++++++++++++- src/custom/PermissionProvider.tsx | 26 +++++- src/custom/permissions.tsx | 100 ++++++++++++--------- src/index.tsx | 1 + 4 files changed, 184 insertions(+), 48 deletions(-) diff --git a/src/__testing__/permissionKeySet.test.tsx b/src/__testing__/permissionKeySet.test.tsx index e001b8e03..05a70e98f 100644 --- a/src/__testing__/permissionKeySet.test.tsx +++ b/src/__testing__/permissionKeySet.test.tsx @@ -4,9 +4,10 @@ import { NavigationNavbar, type NavigationItem } from '../custom/NavigationNavba import { PermissionProvider, useHasPermission, + usePermissionDocsUrl, type PermissionKeySpec } from '../custom/PermissionProvider'; -import { PermissionShield } from '../custom/permissions'; +import { PermissionSessionContext, PermissionShield } from '../custom/permissions'; import { SistentThemeProvider } from '../theme'; // `permissionKey` accepts a bare `Key` (the original form) or a key set with an @@ -114,11 +115,16 @@ describe('useHasPermission — empty and malformed sets deny', () => { }); }); -const renderShield = (permissionKey: PermissionKeySpec, granted: string[] = []) => +const renderShield = ( + permissionKey: PermissionKeySpec, + granted: string[] = [], + providerDocsUrl?: string | null, + shieldDocsUrl?: string | null +) => render( - - + + @@ -166,6 +172,97 @@ describe('PermissionShield tooltip', () => { }); }); +describe('Permission documentation link configuration', () => { + it('omits Key Reference link by default when no docsUrl is provided', () => { + const { container } = renderShield(VIEW_ORG); + openTooltip(container); + const tooltip = screen.getByRole('tooltip'); + expect(within(tooltip).queryByRole('link', { name: /key reference/i })).toBeNull(); + }); + + it('renders Key Reference link when docsUrl is provided by PermissionProvider', () => { + const { container } = renderShield(VIEW_ORG, [], 'https://custom.docs.io/permissions'); + openTooltip(container); + const tooltip = screen.getByRole('tooltip'); + const link = within(tooltip).getByRole('link', { name: /key reference/i }); + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe('https://custom.docs.io/permissions'); + }); + + it('renders Key Reference link when docsUrl prop is provided on PermissionShield', () => { + const { container } = renderShield( + VIEW_ORG, + [], + undefined, + 'https://shield.docs.io/permissions' + ); + openTooltip(container); + const tooltip = screen.getByRole('tooltip'); + const link = within(tooltip).getByRole('link', { name: /key reference/i }); + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe('https://shield.docs.io/permissions'); + }); + + it('prioritizes PermissionShield docsUrl prop over PermissionProvider docsUrl', () => { + const { container } = renderShield( + VIEW_ORG, + [], + 'https://provider.docs.io/permissions', + 'https://override.docs.io/permissions' + ); + openTooltip(container); + const tooltip = screen.getByRole('tooltip'); + const link = within(tooltip).getByRole('link', { name: /key reference/i }); + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe('https://override.docs.io/permissions'); + }); + + it('omits Key Reference link when docsUrl={null} is explicitly passed to PermissionShield', () => { + const { container } = renderShield(VIEW_ORG, [], 'https://provider.docs.io/permissions', null); + openTooltip(container); + const tooltip = screen.getByRole('tooltip'); + expect(within(tooltip).queryByRole('link', { name: /key reference/i })).toBeNull(); + }); + + it('supports docsUrl in standalone PermissionSessionContext', () => { + const { rerender } = render( + + + + ); + + const link = screen.getByRole('link', { name: /key reference/i }); + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe('https://custom.card.io/permissions'); + + rerender( + + + + ); + + expect(screen.queryByRole('link', { name: /key reference/i })).toBeNull(); + }); + + it('usePermissionDocsUrl returns docsUrl from provider or undefined without provider', () => { + const { result: noProvider } = renderHook(() => usePermissionDocsUrl()); + expect(noProvider.current).toBeUndefined(); + + const { result: withProvider } = renderHook(() => usePermissionDocsUrl(), { + wrapper: ({ children }) => ( + + {children} + + ) + }); + expect(withProvider.current).toBe('https://docs.test/ref'); + }); +}); + const SECTION_KEYS = { anyOf: [VIEW_ALL_ORGS, VIEW_ORG] }; const renderNavbar = (items: NavigationItem[], granted: string[] = []) => diff --git a/src/custom/PermissionProvider.tsx b/src/custom/PermissionProvider.tsx index 7065805b1..1656386c5 100644 --- a/src/custom/PermissionProvider.tsx +++ b/src/custom/PermissionProvider.tsx @@ -1,7 +1,6 @@ import { Key } from '@meshery/schemas/permissions'; import React, { createContext, useContext } from 'react'; - /** * Determines how a component responds when the user lacks the required permission. * @@ -110,8 +109,13 @@ export interface PermissionProviderValue { /** Optional user context rendered inside the shield tooltip. */ userContext?: PermissionUserContext; -} + /** + * Optional documentation URL for permissions reference (rendered as a "Key Reference" link). + * When omitted or null, the link is omitted. + */ + docsUrl?: string | null; +} const PermissionContext = createContext(null); @@ -131,6 +135,12 @@ export interface PermissionProviderProps { */ userContext?: PermissionUserContext; + /** + * Optional documentation URL for permissions reference (rendered as a "Key Reference" link). + * When omitted or null, the link is omitted. + */ + docsUrl?: string | null; + children: React.ReactNode; } @@ -158,9 +168,10 @@ export interface PermissionProviderProps { export const PermissionProvider: React.FC = ({ userHasPermission, userContext, + docsUrl, children }) => ( - + {children} ); @@ -222,3 +233,12 @@ export const usePermissionUserContext = (): PermissionUserContext | undefined => const ctx = usePermission(); return ctx?.userContext; }; + +/** + * Retrieve the documentation URL supplied to `PermissionProvider`. + * Used internally by `PermissionSessionContext` to render the permissions reference link. + */ +export const usePermissionDocsUrl = (): string | null | undefined => { + const ctx = usePermission(); + return ctx?.docsUrl; +}; diff --git a/src/custom/permissions.tsx b/src/custom/permissions.tsx index 9da7b6330..e1b639930 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -16,6 +16,7 @@ import { UsersIcon } from '../icons/Users'; import { getPermissionKeyCombinator, getPermissionKeys, + usePermissionDocsUrl, usePermissionUserContext, useUnmetPermissionKeys, type PermissionKeySpec @@ -67,6 +68,11 @@ export interface PermissionShieldProps { permissionKey: PermissionKeySpec; children: React.ReactNode; variant?: 'inline' | 'badge'; + /** + * Optional documentation URL for permissions reference (rendered as a "Key Reference" link). + * Overrides `docsUrl` from `PermissionProvider`. Pass `null` to explicitly hide the link. + */ + docsUrl?: string | null; } /** Distinct, defined values in first-seen order — used for the metadata chips. */ @@ -104,6 +110,11 @@ export interface PermissionSessionContextProps { categories?: string[]; /** Subcategory labels derived from the keys. Shown as chips after categories. */ subcategories?: string[]; + /** + * Optional documentation URL for permissions reference (rendered as a "Key Reference" link). + * Overrides `docsUrl` from `PermissionProvider`. Pass `null` to explicitly hide the link. + */ + docsUrl?: string | null; } /** @@ -124,12 +135,16 @@ export const PermissionSessionContext: React.FC = displayedKeys: displayedKeysProp, subtitle: subtitleProp, categories: categoriesProp, - subcategories: subcategoriesProp + subcategories: subcategoriesProp, + docsUrl: docsUrlProp }) => { const [copiedKeyId, setCopiedKeyId] = React.useState(null); const userContext = usePermissionUserContext(); + const contextDocsUrl = usePermissionDocsUrl(); const theme = useTheme(); + const docsUrl = docsUrlProp !== undefined ? docsUrlProp : contextDocsUrl; + // Self-resolve when permissionKey is provided (same logic as PermissionShield) const unmetKeys = useUnmetPermissionKeys(permissionKeyProp); const selfResolved = !!permissionKeyProp; @@ -143,9 +158,7 @@ export const PermissionSessionContext: React.FC = const declaredKeys = getPermissionKeys(permissionKeyProp); displayedKeys = unmetKeys.length > 0 ? unmetKeys : declaredKeys; const combinator = getPermissionKeyCombinator(permissionKeyProp); - const keyNames = displayedKeys - .map((key) => key.function || 'Access Restricted') - .join(', '); + const keyNames = displayedKeys.map((key) => key.function || 'Access Restricted').join(', '); subtitle = combinator === 'anyOf' && keyNames ? `Needs any of: ${keyNames}` @@ -272,7 +285,11 @@ export const PermissionSessionContext: React.FC = sx={{ display: 'inline-flex', cursor: 'pointer', - color: copied ? palette.accent : isCard ? palette.muted : 'rgba(255, 255, 255, 0.7)', + color: copied + ? palette.accent + : isCard + ? palette.muted + : 'rgba(255, 255, 255, 0.7)', transition: 'color 0.2s ease', '&:hover': { color: palette.accent @@ -343,39 +360,41 @@ export const PermissionSessionContext: React.FC = ))} )} - e.stopPropagation()} - sx={{ - display: 'inline-flex', - alignItems: 'baseline', - fontSize: `${0.75 * baseFontScale}rem`, - color: palette.accent, - textDecoration: 'none', - fontWeight: 600, - marginLeft: 'auto', - '&:hover': { - textDecoration: 'underline' - } - }} - > - Key Reference - e.stopPropagation()} sx={{ - fontSize: '10px', - ml: '2px', - verticalAlign: 'super', - lineHeight: 0, - position: 'relative', - top: '-0.3em' + display: 'inline-flex', + alignItems: 'baseline', + fontSize: `${0.75 * baseFontScale}rem`, + color: palette.accent, + textDecoration: 'none', + fontWeight: 600, + marginLeft: 'auto', + '&:hover': { + textDecoration: 'underline' + } }} > - - - + Key Reference + + + + + )} )} @@ -543,7 +562,8 @@ export const PermissionSessionContext: React.FC = export const PermissionShield: React.FC = ({ permissionKey, children, - variant = 'inline' + variant = 'inline', + docsUrl }) => { const [open, setOpen] = React.useState(false); const uniqueId = React.useId(); @@ -586,10 +606,7 @@ export const PermissionShield: React.FC = ({ // to PermissionSessionContext's self-resolving path. This keeps the tooltip // and card rendering paths in sync — both use the same internal resolution logic. const tooltipTitle = ( - + ); const isBadge = variant === 'badge'; @@ -796,11 +813,12 @@ export const createCanShow = ( // Re-export PermissionProvider types and hooks export { - PermissionProvider, getPermissionKeys, isPermissionKeySet, + PermissionProvider, useHasPermission, usePermission, + usePermissionDocsUrl, usePermissionUserContext, useUnmetPermissionKeys } from './PermissionProvider'; diff --git a/src/index.tsx b/src/index.tsx index b098906ca..6ae7898ae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,6 +58,7 @@ export { isPermissionKeySet, useHasPermission, usePermission, + usePermissionDocsUrl, usePermissionUserContext, useUnmetPermissionKeys, type Key, From 73f5235847fcadf3d8292045ddd827fb6a4bf2f9 Mon Sep 17 00:00:00 2001 From: Cooper Fitzgerald Date: Mon, 9 Nov 2026 14:34:21 +0000 Subject: [PATCH 2/3] fix(permissions): memoize PermissionProvider context value Wrap the PermissionContext value in useMemo so consumers do not re-render and re-run permission checks on every provider render when permission data is unchanged. The value refreshes whenever a prop identity changes, so a host swapping in a new evaluator or user context still propagates. Addresses CodeRabbit review feedback on #1895. Signed-off-by: Cooper Fitzgerald --- src/custom/PermissionProvider.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/custom/PermissionProvider.tsx b/src/custom/PermissionProvider.tsx index 1656386c5..68bbdf5e2 100644 --- a/src/custom/PermissionProvider.tsx +++ b/src/custom/PermissionProvider.tsx @@ -1,5 +1,5 @@ import { Key } from '@meshery/schemas/permissions'; -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; /** * Determines how a component responds when the user lacks the required permission. @@ -170,11 +170,17 @@ export const PermissionProvider: React.FC = ({ userContext, docsUrl, children -}) => ( - - {children} - -); +}) => { + // Keep the context value stable across renders so consumers do not + // re-render (and re-run permission checks) when permission data is + // unchanged. The value refreshes whenever a prop identity changes, so + // a host that swaps in a new evaluator or user context still propagates. + const value = useMemo( + () => ({ userHasPermission, userContext, docsUrl }), + [userHasPermission, userContext, docsUrl] + ); + return {children}; +}; PermissionProvider.displayName = 'PermissionProvider'; From 65c3944cdd3caa81e14adc7ea51162590e63d023 Mon Sep 17 00:00:00 2001 From: Cooper Fitzgerald Date: Mon, 9 Nov 2026 14:47:19 +0000 Subject: [PATCH 3/3] Revert "fix(permissions): memoize PermissionProvider context value" The memoized value only refreshes when a prop identity changes, so a host passing a stable evaluator over mutable permission state (for example the CASL pattern in this component's own docs) would not see permission updates propagate to consumers. A fresh value on every provider render is the correct contract here; the perf concern is minor next to stale permission state. This reverts commit 73f5235847fcadf3d8292045ddd827fb6a4bf2f9 and restores the provider behavior this PR was approved with. Signed-off-by: Cooper Fitzgerald --- src/custom/PermissionProvider.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/custom/PermissionProvider.tsx b/src/custom/PermissionProvider.tsx index 68bbdf5e2..1656386c5 100644 --- a/src/custom/PermissionProvider.tsx +++ b/src/custom/PermissionProvider.tsx @@ -1,5 +1,5 @@ import { Key } from '@meshery/schemas/permissions'; -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useContext } from 'react'; /** * Determines how a component responds when the user lacks the required permission. @@ -170,17 +170,11 @@ export const PermissionProvider: React.FC = ({ userContext, docsUrl, children -}) => { - // Keep the context value stable across renders so consumers do not - // re-render (and re-run permission checks) when permission data is - // unchanged. The value refreshes whenever a prop identity changes, so - // a host that swaps in a new evaluator or user context still propagates. - const value = useMemo( - () => ({ userHasPermission, userContext, docsUrl }), - [userHasPermission, userContext, docsUrl] - ); - return {children}; -}; +}) => ( + + {children} + +); PermissionProvider.displayName = 'PermissionProvider';