diff --git a/src/custom/permissions.tsx b/src/custom/permissions.tsx index 2e0952c16..944c37677 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -2,6 +2,7 @@ import { Key } from '@meshery/schemas/permissions'; import KeyIcon from '@mui/icons-material/Key'; import LaunchIcon from '@mui/icons-material/Launch'; import SecurityIcon from '@mui/icons-material/Security'; +import { useTheme } from '@mui/material/styles'; import React from 'react'; import type { MissingCapabilityReason, @@ -21,12 +22,6 @@ import { } from './PermissionProvider'; export type { Key }; -const DIVIDER_SX = { - height: '1px', - background: 'rgba(255, 255, 255, 0.1)', - my: 1.25 -}; - /** Monochrome fill used for tooltip-context icons */ const CONTEXT_ICON_COLOR = '#9E9E9E'; const CONTEXT_ICON_SIZE = '14'; @@ -79,87 +74,144 @@ const uniqueDefined = (values: (string | undefined)[]): string[] => Array.from(new Set(values.filter((v): v is string => !!v))); /** - * PermissionShield Wrapper Component + * Props for `PermissionSessionContext`. * - * Renders children with a shield icon overlay showing permission metadata. - * It never decides *whether* to block — the consumer owns the disabled state - * (e.g. via CAN()), and a key the user does in fact hold is still displayed. - * It does read the provider to work out *which* of the declared keys are unmet, - * so a key set can name every missing key instead of only one. + * When `displayedKeys` is provided the component renders the full permission + * detail (key names, descriptions, categories, reference link) followed by the + * user context section. When omitted, only the user/org/role context section is + * rendered — useful for generic 403 error pages that have no specific key. + */ +export interface PermissionSessionContextProps { + /** + * `'tooltip'` — compact, dark-background styling for inside a tooltip. + * `'card'` — theme-aware, scaled-up styling for full-page display. + * @default 'tooltip' + */ + variant?: 'tooltip' | 'card'; + /** + * Permission key spec to resolve. When provided, the component resolves + * `displayedKeys`, `subtitle`, `categories`, and `subcategories` automatically + * — exactly the same resolution that `PermissionShield` does. + * + * Takes priority over manually supplied `displayedKeys`. + */ + permissionKey?: PermissionKeySpec; + /** The permission keys to display detail for. Omit to show only user context. */ + displayedKeys?: Key[]; + /** Subtitle line (e.g. "Needs any of: …"). Shown only when `displayedKeys` is provided. */ + subtitle?: string; + /** Category labels derived from the keys. Shown as chips. */ + categories?: string[]; + /** Subcategory labels derived from the keys. Shown as chips after categories. */ + subcategories?: string[]; +} + +/** + * Standalone component that renders the permission session context — the same + * content shown inside `PermissionShield`'s tooltip. Extracted so that consumers + * (e.g. error pages) can embed it inline without duplicating the JSX. * - * Usage in base components: when `disabled` is true AND `permissionKey` is provided, - * the component automatically wraps itself in PermissionShield. + * Reads user/org/role context from the nearest `PermissionProvider`. + * + * Two usage modes: + * 1. **Self-resolving** (`permissionKey`): pass the key spec and the component + * resolves everything internally — same logic as `PermissionShield`. + * 2. **Pre-resolved** (`displayedKeys` etc.): pass already-resolved data. */ -export const PermissionShield: React.FC = ({ - permissionKey, - children, - variant = 'inline' +export const PermissionSessionContext: React.FC = ({ + variant = 'tooltip', + permissionKey: permissionKeyProp, + displayedKeys: displayedKeysProp, + subtitle: subtitleProp, + categories: categoriesProp, + subcategories: subcategoriesProp }) => { - const [open, setOpen] = React.useState(false); const [copiedKeyId, setCopiedKeyId] = React.useState(null); - const uniqueId = React.useId(); const userContext = usePermissionUserContext(); - const unmetKeys = useUnmetPermissionKeys(permissionKey); - - const handleClose = () => { - setOpen(false); - }; - - const handleToggle = (e: React.MouseEvent) => { - e.stopPropagation(); - setOpen((prev) => { - const next = !prev; - if (next) { - window.dispatchEvent( - new CustomEvent('permission-shield-opened', { detail: { id: uniqueId } }) - ); - } - return next; - }); - }; - - React.useEffect(() => { - const handleOtherOpen = (e: Event) => { - const customEvent = e as CustomEvent; - if (customEvent.detail?.id !== uniqueId) { - setOpen(false); - } - }; - window.addEventListener('permission-shield-opened', handleOtherOpen); - return () => { - window.removeEventListener('permission-shield-opened', handleOtherOpen); - }; - }, [uniqueId]); - - if (!permissionKey) { - return <>{children}; + const theme = useTheme(); + + // Self-resolve when permissionKey is provided (same logic as PermissionShield) + const unmetKeys = useUnmetPermissionKeys(permissionKeyProp); + const selfResolved = !!permissionKeyProp; + + let displayedKeys = displayedKeysProp; + let subtitle = subtitleProp; + let categories = categoriesProp; + let subcategories = subcategoriesProp; + + if (selfResolved) { + const declaredKeys = getPermissionKeys(permissionKeyProp); + displayedKeys = unmetKeys.length > 0 ? unmetKeys : declaredKeys; + const combinator = getPermissionKeyCombinator(permissionKeyProp); + const keyNames = displayedKeys + .map((key) => key.function || 'Access Restricted') + .join(', '); + subtitle = + combinator === 'anyOf' && keyNames + ? `Needs any of: ${keyNames}` + : combinator === 'allOf' && keyNames + ? `Needs all of: ${keyNames}` + : 'Missing requisite key'; + categories = uniqueDefined(displayedKeys.map((key) => key.category)); + subcategories = uniqueDefined(displayedKeys.map((key) => key.subcategory)); } - // Every key the user is missing, so the tooltip can explain all of them. The - // fallback keeps the single-key rendering identical for a caller that shields - // a key the user does in fact hold — `PermissionShield` is a pure visual - // component and never second-guesses the caller's disabled decision. - const declaredKeys = getPermissionKeys(permissionKey); - const displayedKeys = unmetKeys.length > 0 ? unmetKeys : declaredKeys; - const combinator = getPermissionKeyCombinator(permissionKey); - const keyNames = displayedKeys.map((key) => key.function || 'Access Restricted').join(', '); - const subtitle = - combinator === 'anyOf' && keyNames - ? `Needs any of: ${keyNames}` - : combinator === 'allOf' && keyNames - ? `Needs all of: ${keyNames}` - : 'Missing requisite key'; - const categories = uniqueDefined(displayedKeys.map((key) => key.category)); - const subcategories = uniqueDefined(displayedKeys.map((key) => key.subcategory)); + const isCard = variant === 'card'; + const hasKeys = displayedKeys && displayedKeys.length > 0; + + // Variant-aware palette: tooltip uses hard-coded dark-bg colors; + // card adapts to the current MUI theme. + const palette = isCard + ? { + bg: theme.palette.background.paper, + color: theme.palette.text.primary, + muted: theme.palette.text.secondary, + subtle: theme.palette.text.disabled, + divider: theme.palette.divider, + contextBg: theme.palette.action.hover, + contextBorder: theme.palette.divider, + chipBg: theme.palette.action.selected, + chipColor: theme.palette.text.secondary, + accent: theme.palette.primary.main, + keyColor: theme.palette.text.primary + } + : { + bg: 'transparent', + color: '#FFFFFF', + muted: '#9E9E9E', + subtle: 'rgba(255, 255, 255, 0.45)', + divider: 'rgba(255, 255, 255, 0.1)', + contextBg: 'rgba(255, 255, 255, 0.02)', + contextBorder: 'rgba(255, 255, 255, 0.05)', + chipBg: 'rgba(255, 255, 255, 0.08)', + chipColor: 'rgba(255, 255, 255, 0.8)', + accent: '#EBC024', + keyColor: '#FFFFFF' + }; + + const dividerSx = { height: '1px', background: palette.divider, my: isCard ? 1.5 : 1.25 }; + const baseFontScale = isCard ? 1.25 : 1; + + const iconSize = isCard ? '18' : CONTEXT_ICON_SIZE; + const iconColor = isCard ? theme.palette.text.secondary : CONTEXT_ICON_COLOR; - const tooltipTitle = ( - - {/* Title: AUTHORIZATION REQUIRED — medium gray */} + return ( + + {/* Title: AUTHORIZATION REQUIRED */} = ({ {/* Subtitle */} - - {subtitle} - + {subtitle && ( + + {subtitle} + + )} {/* Divider */} - - - {/* One block per unmet key: KeyIcon (doubles as copy button) + key name, - then the key's description */} - {displayedKeys.map((key, index) => { - const copied = copiedKeyId === (key.id || `#${index}`); - return ( - - - - { - e.stopPropagation(); - navigator.clipboard.writeText(key.id || ''); - setCopiedKeyId(key.id || `#${index}`); - setTimeout(() => setCopiedKeyId(null), 1500); - }} + {(hasKeys || subtitle) && } + + {/* One block per key: KeyIcon (copy button) + key name, then description */} + {hasKeys && + displayedKeys!.map((key, index) => { + const copied = copiedKeyId === (key.id || `#${index}`); + const copyKeyId = (e: React.SyntheticEvent) => { + e.stopPropagation(); + const id = key.id || ''; + void navigator.clipboard + ?.writeText(id) + .then(() => { + setCopiedKeyId(key.id || `#${index}`); + setTimeout(() => setCopiedKeyId(null), 1500); + }) + .catch(() => undefined); + }; + + return ( + + + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + copyKeyId(e); + } + }} + sx={{ + display: 'inline-flex', + cursor: 'pointer', + color: copied ? palette.accent : isCard ? palette.muted : 'rgba(255, 255, 255, 0.7)', + transition: 'color 0.2s ease', + '&:hover': { + color: palette.accent + } + }} + > + + + + - - - - - {key.function || 'Access Restricted'} - - + {key.function || 'Access Restricted'} + + - {/* Description — italicized, equal padding both sides, no divider from key name */} - - - {key.description || - `Allows you to perform the ${key.function || 'selected'} operation.`} - - - - ); - })} + {/* Description */} + + + {key.description || + `Allows you to perform the ${key.function || 'selected'} operation.`} + + + + ); + })} {/* Divider */} - + {hasKeys && } - {/* Bottom row: Category/Subcategory chips (left) + Key Reference link (right) */} - - - {[...categories, ...subcategories].map((label, index) => ( - - ))} - - e.stopPropagation()} + {/* Bottom row: Category/Subcategory chips + Key Reference link */} + {hasKeys && Boolean(categories?.length || subcategories?.length) && ( + - Key Reference - + {[...(categories || []), ...(subcategories || [])].map((label, index) => ( + + ))} + + 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, + '&:hover': { + textDecoration: 'underline' + } }} > - - - - + Key Reference + + + + + + )} - {/* User / Org / Role context — provided via PermissionProvider */} + {/* User / Org / Role context — from PermissionProvider */} {userContext && (userContext.userName || userContext.orgName) && ( <> - + {userContext.userName && ( - + - - + + User = ({ )} - + - - + + Org = ({ {userContext.orgName || 'Private Org'} - + - - + + Role(s) = ({ @@ -393,6 +523,71 @@ export const PermissionShield: React.FC = ({ )} ); +}; + +/** + * PermissionShield Wrapper Component + * + * Renders children with a shield icon overlay showing permission metadata. + * It never decides *whether* to block — the consumer owns the disabled state + * (e.g. via CAN()), and a key the user does in fact hold is still displayed. + * It does read the provider to work out *which* of the declared keys are unmet, + * so a key set can name every missing key instead of only one. + * + * Usage in base components: when `disabled` is true AND `permissionKey` is provided, + * the component automatically wraps itself in PermissionShield. + */ +export const PermissionShield: React.FC = ({ + permissionKey, + children, + variant = 'inline' +}) => { + const [open, setOpen] = React.useState(false); + const uniqueId = React.useId(); + + const handleClose = () => { + setOpen(false); + }; + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + setOpen((prev) => { + const next = !prev; + if (next) { + window.dispatchEvent( + new CustomEvent('permission-shield-opened', { detail: { id: uniqueId } }) + ); + } + return next; + }); + }; + + React.useEffect(() => { + const handleOtherOpen = (e: Event) => { + const customEvent = e as CustomEvent; + if (customEvent.detail?.id !== uniqueId) { + setOpen(false); + } + }; + window.addEventListener('permission-shield-opened', handleOtherOpen); + return () => { + window.removeEventListener('permission-shield-opened', handleOtherOpen); + }; + }, [uniqueId]); + + if (!permissionKey) { + return <>{children}; + } + + // Delegate all key resolution (unmet keys, subtitle, categories, subcategories) + // 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'; diff --git a/src/index.tsx b/src/index.tsx index fce5d8899..09be23331 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -73,6 +73,7 @@ export { export { PermissionProvider, + PermissionSessionContext, PermissionShield, isPermissionKeySet, useHasPermission, @@ -85,6 +86,7 @@ export { type PermissionKeySpec, type PermissionProviderProps, type PermissionProviderValue, + type PermissionSessionContextProps, type PermissionShieldProps, type PermissionUserContext } from './custom/permissions';