Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 101 additions & 4 deletions src/__testing__/permissionKeySet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
<SistentThemeProvider>
<PermissionProvider userHasPermission={granting(...granted)}>
<PermissionShield permissionKey={permissionKey}>
<PermissionProvider userHasPermission={granting(...granted)} docsUrl={providerDocsUrl}>
<PermissionShield permissionKey={permissionKey} docsUrl={shieldDocsUrl}>
<button>Organizations</button>
</PermissionShield>
</PermissionProvider>
Expand Down Expand Up @@ -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(
<SistentThemeProvider>
<PermissionSessionContext
variant="card"
displayedKeys={[VIEW_ORG]}
docsUrl="https://custom.card.io/permissions"
/>
</SistentThemeProvider>
);

const link = screen.getByRole('link', { name: /key reference/i });
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('https://custom.card.io/permissions');

rerender(
<SistentThemeProvider>
<PermissionSessionContext variant="card" displayedKeys={[VIEW_ORG]} docsUrl={null} />
</SistentThemeProvider>
);

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 }) => (
<PermissionProvider userHasPermission={granting()} docsUrl="https://docs.test/ref">
{children}
</PermissionProvider>
)
});
expect(withProvider.current).toBe('https://docs.test/ref');
});
});

const SECTION_KEYS = { anyOf: [VIEW_ALL_ORGS, VIEW_ORG] };

const renderNavbar = (items: NavigationItem[], granted: string[] = []) =>
Expand Down
26 changes: 23 additions & 3 deletions src/custom/PermissionProvider.tsx
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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<PermissionProviderValue | null>(null);

Expand All @@ -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;
}

Expand Down Expand Up @@ -158,9 +168,10 @@ export interface PermissionProviderProps {
export const PermissionProvider: React.FC<PermissionProviderProps> = ({
userHasPermission,
userContext,
docsUrl,
children
}) => (
<PermissionContext.Provider value={{ userHasPermission, userContext }}>
<PermissionContext.Provider value={{ userHasPermission, userContext, docsUrl }}>
{children}
</PermissionContext.Provider>
);
Expand Down Expand Up @@ -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;
};
100 changes: 59 additions & 41 deletions src/custom/permissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { UsersIcon } from '../icons/Users';
import {
getPermissionKeyCombinator,
getPermissionKeys,
usePermissionDocsUrl,
usePermissionUserContext,
useUnmetPermissionKeys,
type PermissionKeySpec
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -124,12 +135,16 @@ export const PermissionSessionContext: React.FC<PermissionSessionContextProps> =
displayedKeys: displayedKeysProp,
subtitle: subtitleProp,
categories: categoriesProp,
subcategories: subcategoriesProp
subcategories: subcategoriesProp,
docsUrl: docsUrlProp
}) => {
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>(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;
Expand All @@ -143,9 +158,7 @@ export const PermissionSessionContext: React.FC<PermissionSessionContextProps> =
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}`
Expand Down Expand Up @@ -272,7 +285,11 @@ export const PermissionSessionContext: React.FC<PermissionSessionContextProps> =
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
Expand Down Expand Up @@ -343,39 +360,41 @@ export const PermissionSessionContext: React.FC<PermissionSessionContextProps> =
))}
</Box>
)}
<Link
href="https://docs.meshery.io/reference/references/permissions/"
target="_blank"
rel="noopener noreferrer"
onClick={(e: React.MouseEvent) => 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
<Box
component="span"
{docsUrl && (
<Link
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e: React.MouseEvent) => 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'
}
}}
>
<LaunchIcon style={{ fontSize: '10px', width: '10px', height: '10px' }} />
</Box>
</Link>
Key Reference
<Box
component="span"
sx={{
fontSize: '10px',
ml: '2px',
verticalAlign: 'super',
lineHeight: 0,
position: 'relative',
top: '-0.3em'
}}
>
<LaunchIcon style={{ fontSize: '10px', width: '10px', height: '10px' }} />
</Box>
</Link>
)}
</Box>
)}

Expand Down Expand Up @@ -543,7 +562,8 @@ export const PermissionSessionContext: React.FC<PermissionSessionContextProps> =
export const PermissionShield: React.FC<PermissionShieldProps> = ({
permissionKey,
children,
variant = 'inline'
variant = 'inline',
docsUrl
}) => {
const [open, setOpen] = React.useState(false);
const uniqueId = React.useId();
Expand Down Expand Up @@ -586,10 +606,7 @@ export const PermissionShield: React.FC<PermissionShieldProps> = ({
// 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 = (
<PermissionSessionContext
variant="tooltip"
permissionKey={permissionKey}
/>
<PermissionSessionContext variant="tooltip" permissionKey={permissionKey} docsUrl={docsUrl} />
);

const isBadge = variant === 'badge';
Expand Down Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {
isPermissionKeySet,
useHasPermission,
usePermission,
usePermissionDocsUrl,
usePermissionUserContext,
useUnmetPermissionKeys,
type Key,
Expand Down
Loading