diff --git a/src/base/Button/Button.tsx b/src/base/Button/Button.tsx index 534ae2de0..9eefa74a1 100644 --- a/src/base/Button/Button.tsx +++ b/src/base/Button/Button.tsx @@ -1,13 +1,34 @@ import { Button as MuiButton, type ButtonProps as MuiButtonProps } from '@mui/material'; +import React from 'react'; +import { Key, PermissionShield } from '../../custom/permissions'; export interface ButtonProps extends MuiButtonProps { label?: string; children?: React.ReactNode; + permissionKey?: Key; } -export function Button({ label, children, ...props }: ButtonProps): JSX.Element { +export function Button({ + label, + children, + permissionKey, + disabled, + ...props +}: ButtonProps): JSX.Element { + // When disabled AND permissionKey is provided, show the shield overlay + if (disabled && permissionKey) { + return ( + + + {label} + {children} + + + ); + } + return ( - + {label} {children} diff --git a/src/base/IconButton/IconButton.tsx b/src/base/IconButton/IconButton.tsx index f21709a23..778158ee2 100644 --- a/src/base/IconButton/IconButton.tsx +++ b/src/base/IconButton/IconButton.tsx @@ -3,12 +3,26 @@ import { type IconButtonProps as MuiIconButtonProps } from '@mui/material'; import React from 'react'; +import { Key, PermissionShield } from '../../custom/permissions'; -export type IconButtonProps = MuiIconButtonProps; +export interface IconButtonProps extends MuiIconButtonProps { + permissionKey?: Key; +} -export const IconButton = React.forwardRef((props, ref) => ( - -)); +export const IconButton = React.forwardRef((props, ref) => { + const { permissionKey, disabled, ...rest } = props; + + // When disabled AND permissionKey is provided, show the shield overlay + if (disabled && permissionKey) { + return ( + + + + ); + } + + return ; +}); IconButton.displayName = 'IconButton'; diff --git a/src/base/ListItem/ListItem.tsx b/src/base/ListItem/ListItem.tsx index bcb609aec..eb3ec7981 100644 --- a/src/base/ListItem/ListItem.tsx +++ b/src/base/ListItem/ListItem.tsx @@ -1,8 +1,29 @@ import { ListItem as MuiListItem, ListItemProps as MuiListItemProps } from '@mui/material'; import React from 'react'; +import { Key, PermissionShield } from '../../custom/permissions'; -const ListItem = React.forwardRef((props, ref) => { - return ; +export interface ListItemProps extends MuiListItemProps { + permissionKey?: Key; + disabled?: boolean; +} + +const ListItem = React.forwardRef((props, ref) => { + const { permissionKey, disabled, ...rest } = props; + + // When disabled AND permissionKey is provided, show the shield overlay + // Note: MUI ListItem doesn't have a native `disabled` prop, so we only use + // it as a custom guard for the PermissionShield wrapper + if (disabled && permissionKey) { + return ( + + + + ); + } + + return ; }); +ListItem.displayName = 'ListItem'; + export default ListItem; diff --git a/src/base/ListItemButton/ListItemButton.tsx b/src/base/ListItemButton/ListItemButton.tsx index f19e4f3e9..d5125aa76 100644 --- a/src/base/ListItemButton/ListItemButton.tsx +++ b/src/base/ListItemButton/ListItemButton.tsx @@ -3,9 +3,28 @@ import { ListItemButtonProps as MuiListItemButtonProps } from '@mui/material'; import React from 'react'; +import { Key, PermissionShield } from '../../custom/permissions'; -const ListItemButton = React.forwardRef((props, ref) => { - return ; +export interface ListItemButtonProps extends MuiListItemButtonProps { + permissionKey?: Key; +} + +const ListItemButton = React.forwardRef((props, ref) => { + const { permissionKey, disabled, ...rest } = props; + + // When disabled AND permissionKey is provided, show the shield overlay + if (disabled && permissionKey) { + return ( + + + + ); + } + + return ; }); +ListItemButton.displayName = 'ListItemButton'; + export { ListItemButton }; +export default ListItemButton; diff --git a/src/base/MenuItem/MenuItem.tsx b/src/base/MenuItem/MenuItem.tsx index 397ef39a9..d46d2ee7a 100644 --- a/src/base/MenuItem/MenuItem.tsx +++ b/src/base/MenuItem/MenuItem.tsx @@ -1,7 +1,24 @@ import { MenuItem as MuiMenuItem, MenuItemProps as MuiMenuItemProps } from '@mui/material'; +import React from 'react'; +import { Key, PermissionShield } from '../../custom/permissions'; -export function MenuItem(props: MuiMenuItemProps): JSX.Element { - return ; +export interface MenuItemProps extends MuiMenuItemProps { + permissionKey?: Key; +} + +export function MenuItem(props: MenuItemProps): JSX.Element { + const { permissionKey, disabled, ...rest } = props; + + // When disabled AND permissionKey is provided, show the shield overlay + if (disabled && permissionKey) { + return ( + + + + ); + } + + return ; } export default MenuItem; diff --git a/src/custom/permissions.tsx b/src/custom/permissions.tsx index 9799dd19c..ff78c5672 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -1,10 +1,35 @@ -// import { CAN, getCapabilitiesRegistry, getMesheryEventBus } from '@/globals/mesherySdk'; import React from 'react'; +import { Box, Typography, ClickAwayListener, Chip } from '@mui/material'; import { EventBus } from '../actors/eventBus'; +import Tooltip from '../base/Tooltip/Tooltip'; +import ShieldIcon from '@mui/icons-material/Shield'; + +const SECTION_HEADING_SX = { + fontSize: '0.7rem', + fontWeight: 700, + letterSpacing: '0.06em', + textTransform: 'uppercase' as const, + color: 'rgba(255, 255, 255, 0.7)', + mb: 0.75, +}; + +const DIVIDER_SX = { + height: '1px', + background: 'rgba(255, 255, 255, 0.1)', + my: 1.25, +}; export interface Key { - subject: string; - action: string; + // Backwards compatibility for old CanShow + subject?: string; + action?: string; + + // New Schemas key structure + id?: string; + category?: string; + subcategory?: string; + function?: string; + description?: string; } export type InvertAction = 'disable' | 'hide'; @@ -27,12 +52,287 @@ export type ReasonEvent = MissingPermissionReason | MissingCapabilityReason; export interface HasKeyProps { Key?: Key; - predicate?: (capabilitiesRegistry: unknown) => [boolean, ReasonEvent]; // returns a boolean and an event if the user does not have the permission + predicate?: (capabilitiesRegistry: unknown) => [boolean, ReasonEvent]; children: React.ReactNode; notifyOnclick?: boolean; invert_action?: InvertAction[]; } +export interface PermissionShieldProps { + permissionKey: Key; + children: React.ReactNode; + variant?: 'inline' | 'badge'; +} + +/** + * PermissionShield Wrapper Component + * + * Renders children with a shield icon overlay showing permission metadata. + * This is a pure visual component — it does NOT check permissions itself. + * The consumer is responsible for determining disabled state (e.g. via CAN()). + * + * 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}; + } + + const tooltipTitle = ( + + {/* Header Row */} + + + {permissionKey.function || permissionKey.subject || 'Access Restricted'} + + + {/* Status dot chip */} + + } + sx={{ + background: 'rgba(235, 192, 36, 0.12)', + color: '#EBC024', + fontWeight: 600, + fontSize: '0.7rem', + height: '20px', + border: '1px solid rgba(235, 192, 36, 0.25)', + '& .MuiChip-label': { + paddingLeft: '4px', + paddingRight: '6px', + }, + }} + /> + + + {/* Meta info chips row */} + {permissionKey.category && ( + + + {permissionKey.subcategory && ( + + )} + + )} + + {/* Divider */} + + + {/* Description Section */} + What this permission allows + + + {permissionKey.description || `Allows you to perform the ${permissionKey.function || permissionKey.subject || 'selected'} operation.`} + + + + {/* Resource Details / Key Info */} + {permissionKey.id && ( + <> + + Resource ID + + {permissionKey.id} + + + )} + + ); + + const isBadge = variant === 'badge'; + + return ( + + + + {children} + + + + + + + + + + ); +}; + // returns the children if the user has the permission to view the component or if a key is not provided // if the user does not have the permission to view the component, it will return null or a disabled version of the component specified by the invert_action prop export const createCanShow = ( @@ -45,13 +345,16 @@ export const createCanShow = ( children, notifyOnclick = true, predicate, - invert_action = ['disable'] + invert_action = ['disable'], }: HasKeyProps) => { if (!children) { return null; } - const hasKey = Key?.subject ? CAN(Key?.action, Key?.subject) : true; + const actionString = Key?.id || Key?.action || ''; + const subjectString = Key?.function || Key?.subject || ''; + + const hasKey = subjectString ? CAN(actionString, subjectString) : true; const predicateRes = predicate && predicate(getCapabilitiesRegistry()); const can = predicateRes ? predicateRes[0] && hasKey : hasKey; @@ -59,8 +362,8 @@ export const createCanShow = ( const reason = predicateRes?.[1] || { type: 'MISSING_PERMISSION', data: { - keyId: Key?.action as string - } + keyId: actionString, + }, }; if (can) { @@ -89,22 +392,20 @@ export const createCanShow = ( style={{ cursor: 'pointer', pointerEvents, - opacity: opacity + opacity: opacity, }} onClick={onClick} > - {React.cloneElement(children as React.ReactElement, { + {React.isValidElement(children) ? React.cloneElement(children as React.ReactElement<{ style?: React.CSSProperties; onClick?: React.MouseEventHandler }>, { style: { - ...((children as React.ReactElement).props.style as React.CSSProperties), + ...((children as React.ReactElement<{ style?: React.CSSProperties; onClick?: React.MouseEventHandler }>).props.style || {}), cursor: 'pointer', pointerEvents, - opacity: opacity + opacity: opacity, }, - onClick: onClick - })} + onClick: onClick, + }) : children} ); - - // return null; }; }; diff --git a/src/index.tsx b/src/index.tsx index 6a1180d1e..1129de512 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -45,3 +45,9 @@ export { type QuickDateRangeOption, type UniversalFilterProps } from './custom/UniversalFilter'; + +export { + PermissionShield, + type Key, + type PermissionShieldProps +} from './custom/permissions';