diff --git a/package-lock.json b/package-lock.json index d4565b070..843484d75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@emotion/cache": "^11.14.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^10.0.1", - "@meshery/schemas": "1.3.13", + "@meshery/schemas": "^1.3.25", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@mui/system": "^9.0.0", @@ -3355,9 +3355,9 @@ } }, "node_modules/@meshery/schemas": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@meshery/schemas/-/schemas-1.3.13.tgz", - "integrity": "sha512-VZw8yrKiBrxvJxFysOMQuuPJkDwGZ2DuW7ifPGJ6OxfBygAGIYuN4kofmPTglcW/WPhEYn656HM7/zahSmlcRg==", + "version": "1.3.25", + "resolved": "https://registry.npmjs.org/@meshery/schemas/-/schemas-1.3.25.tgz", + "integrity": "sha512-wsFu2nkIKUtVBLekbDyZ0lesEMOaxEBlyYeqRSa0Y02oMAZ+ubfpnTDPiDEOUvPYxCBQfWx1vYUsbqRaU5odWA==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/package.json b/package.json index 7626f3275..253efe9f8 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@emotion/cache": "^11.14.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^10.0.1", - "@meshery/schemas": "1.3.13", + "@meshery/schemas": "^1.3.25", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@mui/system": "^9.0.0", diff --git a/src/base/Button/Button.tsx b/src/base/Button/Button.tsx index 9eefa74a1..d03051eb7 100644 --- a/src/base/Button/Button.tsx +++ b/src/base/Button/Button.tsx @@ -1,37 +1,56 @@ import { Button as MuiButton, type ButtonProps as MuiButtonProps } from '@mui/material'; import React from 'react'; +import { useHasPermission, type PermissionAction } from '../../custom/PermissionProvider'; import { Key, PermissionShield } from '../../custom/permissions'; export interface ButtonProps extends MuiButtonProps { label?: string; children?: React.ReactNode; permissionKey?: Key; + /** + * Determines behavior when the user lacks the required permission. + * + * - `'showShield'` (default) — disables the button and shows a shield icon + * with a permission-metadata tooltip. + * - `'hide'` — renders nothing. + * + * Ignored when `permissionKey` is not provided. + */ + permissionAction?: PermissionAction; } export function Button({ label, children, permissionKey, + permissionAction = 'showShield', disabled, ...props }: ButtonProps): JSX.Element { - // When disabled AND permissionKey is provided, show the shield overlay - if (disabled && permissionKey) { + const hasPermission = useHasPermission(permissionKey); + + // useHasPermission returns true when no permissionKey is provided (backward compatible) + if (hasPermission) { return ( - - - {label} - {children} - - + + {label} + {children} + ); } + // User LACKS permission → apply the permissionAction + if (permissionAction === 'hide') { + return <>; + } + return ( - - {label} - {children} - + + + {label} + {children} + + ); } diff --git a/src/base/IconButton/IconButton.tsx b/src/base/IconButton/IconButton.tsx index 778158ee2..98605dd90 100644 --- a/src/base/IconButton/IconButton.tsx +++ b/src/base/IconButton/IconButton.tsx @@ -3,25 +3,42 @@ import { type IconButtonProps as MuiIconButtonProps } from '@mui/material'; import React from 'react'; +import { useHasPermission, type PermissionAction } from '../../custom/PermissionProvider'; import { Key, PermissionShield } from '../../custom/permissions'; export interface IconButtonProps extends MuiIconButtonProps { permissionKey?: Key; + /** + * Determines behavior when the user lacks the required permission. + * + * - `'showShield'` (default) — disables the button and shows a shield icon + * with a permission-metadata tooltip. + * - `'hide'` — renders nothing. + * + * Ignored when `permissionKey` is not provided. + */ + permissionAction?: PermissionAction; } export const IconButton = React.forwardRef((props, ref) => { - const { permissionKey, disabled, ...rest } = props; + const { permissionKey, permissionAction = 'showShield', disabled, ...rest } = props; + const hasPermission = useHasPermission(permissionKey); - // When disabled AND permissionKey is provided, show the shield overlay - if (disabled && permissionKey) { - return ( - - - - ); + // useHasPermission returns true when no permissionKey is provided (backward compatible) + if (hasPermission) { + return ; } - return ; + // User LACKS permission → apply the permissionAction + if (permissionAction === 'hide') { + return null; + } + + return ( + + + + ); }); IconButton.displayName = 'IconButton'; diff --git a/src/base/ListItem/ListItem.tsx b/src/base/ListItem/ListItem.tsx index eb3ec7981..af6abbffc 100644 --- a/src/base/ListItem/ListItem.tsx +++ b/src/base/ListItem/ListItem.tsx @@ -1,27 +1,43 @@ import { ListItem as MuiListItem, ListItemProps as MuiListItemProps } from '@mui/material'; import React from 'react'; +import { useHasPermission, type PermissionAction } from '../../custom/PermissionProvider'; import { Key, PermissionShield } from '../../custom/permissions'; export interface ListItemProps extends MuiListItemProps { permissionKey?: Key; disabled?: boolean; + /** + * Determines behavior when the user lacks the required permission. + * + * - `'showShield'` (default) — disables the item and shows a shield icon + * with a permission-metadata tooltip. + * - `'hide'` — renders nothing. + * + * Ignored when `permissionKey` is not provided. + */ + permissionAction?: PermissionAction; } const ListItem = React.forwardRef((props, ref) => { - const { permissionKey, disabled, ...rest } = props; + const { permissionKey, permissionAction = 'showShield', ...rest } = props; + delete (rest as Record).disabled; + const hasPermission = useHasPermission(permissionKey); - // 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 ( - - - - ); + // useHasPermission returns true when no permissionKey is provided (backward compatible) + if (hasPermission) { + return ; } - return ; + // User LACKS permission → apply the permissionAction + if (permissionAction === 'hide') { + return null; + } + + return ( + + + + ); }); ListItem.displayName = 'ListItem'; diff --git a/src/base/ListItemButton/ListItemButton.tsx b/src/base/ListItemButton/ListItemButton.tsx index d5125aa76..84b30819e 100644 --- a/src/base/ListItemButton/ListItemButton.tsx +++ b/src/base/ListItemButton/ListItemButton.tsx @@ -3,25 +3,42 @@ import { ListItemButtonProps as MuiListItemButtonProps } from '@mui/material'; import React from 'react'; +import { useHasPermission, type PermissionAction } from '../../custom/PermissionProvider'; import { Key, PermissionShield } from '../../custom/permissions'; export interface ListItemButtonProps extends MuiListItemButtonProps { permissionKey?: Key; + /** + * Determines behavior when the user lacks the required permission. + * + * - `'showShield'` (default) — disables the button and shows a shield icon + * with a permission-metadata tooltip. + * - `'hide'` — renders nothing. + * + * Ignored when `permissionKey` is not provided. + */ + permissionAction?: PermissionAction; } const ListItemButton = React.forwardRef((props, ref) => { - const { permissionKey, disabled, ...rest } = props; + const { permissionKey, permissionAction = 'showShield', disabled, ...rest } = props; + const hasPermission = useHasPermission(permissionKey); - // When disabled AND permissionKey is provided, show the shield overlay - if (disabled && permissionKey) { - return ( - - - - ); + // useHasPermission returns true when no permissionKey is provided (backward compatible) + if (hasPermission) { + return ; } - return ; + // User LACKS permission → apply the permissionAction + if (permissionAction === 'hide') { + return null; + } + + return ( + + + + ); }); ListItemButton.displayName = 'ListItemButton'; diff --git a/src/base/MenuItem/MenuItem.tsx b/src/base/MenuItem/MenuItem.tsx index d46d2ee7a..e626ab1c1 100644 --- a/src/base/MenuItem/MenuItem.tsx +++ b/src/base/MenuItem/MenuItem.tsx @@ -1,24 +1,41 @@ import { MenuItem as MuiMenuItem, MenuItemProps as MuiMenuItemProps } from '@mui/material'; import React from 'react'; +import { useHasPermission, type PermissionAction } from '../../custom/PermissionProvider'; import { Key, PermissionShield } from '../../custom/permissions'; export interface MenuItemProps extends MuiMenuItemProps { permissionKey?: Key; + /** + * Determines behavior when the user lacks the required permission. + * + * - `'showShield'` (default) — disables the item and shows a shield icon + * with a permission-metadata tooltip. + * - `'hide'` — renders nothing. + * + * Ignored when `permissionKey` is not provided. + */ + permissionAction?: PermissionAction; } export function MenuItem(props: MenuItemProps): JSX.Element { - const { permissionKey, disabled, ...rest } = props; + const { permissionKey, permissionAction = 'showShield', disabled, ...rest } = props; + const hasPermission = useHasPermission(permissionKey); - // When disabled AND permissionKey is provided, show the shield overlay - if (disabled && permissionKey) { - return ( - - - - ); + // useHasPermission returns true when no permissionKey is provided (backward compatible) + if (hasPermission) { + return ; } - return ; + // User LACKS permission → apply the permissionAction + if (permissionAction === 'hide') { + return <>; + } + + return ( + + + + ); } export default MenuItem; diff --git a/src/custom/PermissionProvider.tsx b/src/custom/PermissionProvider.tsx new file mode 100644 index 000000000..d799de516 --- /dev/null +++ b/src/custom/PermissionProvider.tsx @@ -0,0 +1,127 @@ +import { Key } from '@meshery/schemas/permissions'; +import React, { createContext, useContext } from 'react'; + + +/** + * Determines how a component responds when the user lacks the required permission. + * + * - `'hide'` — renders nothing (`null`). + * - `'showShield'` — disables the component, overlays a shield/lock icon, and + * displays permission metadata in a tooltip. + */ +export type PermissionAction = 'hide' | 'showShield'; + +/** + * User context displayed inside the PermissionShield tooltip. + * + * Passed through the provider so that `PermissionShield` never reads from + * `sessionStorage` or any other host-specific storage mechanism. + */ +export interface PermissionUserContext { + userName?: string; + orgName?: string; + roleNames?: string[]; +} + +/** + * Value exposed by `PermissionContext`. + */ +export interface PermissionProviderValue { + /** + * Generic permission evaluator supplied by the host application. + * + * Sistent never knows *how* permissions are checked (CASL, server lookup, + * JWT claim, etc.) — it only calls this function. + */ + userHasPermission: (key: Key) => boolean; + + /** Optional user context rendered inside the shield tooltip. */ + userContext?: PermissionUserContext; +} + + +const PermissionContext = createContext(null); + +// Provider + +export interface PermissionProviderProps { + /** + * A function that returns `true` when the current user holds the given + * permission key. The host application is responsible for implementing + * this — it may delegate to CASL, a server-side API, a JWT claim, etc. + */ + userHasPermission: (key: Key) => boolean; + + /** + * Optional user/org/role context displayed inside the `PermissionShield` + * tooltip. When omitted the tooltip's user-context section is hidden. + */ + userContext?: PermissionUserContext; + + children: React.ReactNode; +} + +/** + * `PermissionProvider` — the single integration point between Sistent's + * permission-aware components and the host application's authorization system. + * + * Mount this near the root of the React tree (alongside your theme provider). + * If no `PermissionProvider` is present, all permission checks default to + * "permitted" — ensuring full backward compatibility. + * + * @example + * ```tsx + * // In _app.tsx — CASL adapter (the ONLY place CASL is referenced) + * import { PermissionProvider } from '@sistent/sistent'; + * import { ability } from '@/utils/can'; + * + * const userHasPermission = (key) => ability.can(key.id, key.function?.toLowerCase()); + * + * + * {children} + * + * ``` + */ +export const PermissionProvider: React.FC = ({ + userHasPermission, + userContext, + children +}) => ( + + {children} + +); + +PermissionProvider.displayName = 'PermissionProvider'; + +// Hooks + +/** + * Access the full `PermissionProviderValue`. + * Returns `null` when no `PermissionProvider` is mounted. + */ +export const usePermission = (): PermissionProviderValue | null => useContext(PermissionContext); + +/** + * Check whether the current user has a given permission key. + * + * Returns `true` when: + * - No `PermissionProvider` is mounted (backward-compatible default), OR + * - No `key` is supplied, OR + * - `userHasPermission(key)` returns `true`. + */ +export const useHasPermission = (key?: Key): boolean => { + const ctx = usePermission(); + if (!key || !ctx) return true; + return ctx.userHasPermission(key); +}; + +/** + * Retrieve the user context supplied to `PermissionProvider`. + * Used internally by `PermissionShield` to display user/org/role info + * without reading from `sessionStorage`. + */ +export const usePermissionUserContext = (): PermissionUserContext | undefined => { + const ctx = usePermission(); + return ctx?.userContext; +}; diff --git a/src/custom/permissions.tsx b/src/custom/permissions.tsx index 463cbcbb2..5e085e4e8 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -1,9 +1,12 @@ +import { Key } from '@meshery/schemas/permissions'; +export type { Key }; import KeyIcon from '@mui/icons-material/Key'; import LaunchIcon from '@mui/icons-material/Launch'; import SecurityIcon from '@mui/icons-material/Security'; import React from 'react'; import { EventBus } from '../actors/eventBus'; import { Box, Chip, ClickAwayListener, Link, Tooltip, Typography } from '../base'; +import { usePermissionUserContext } from './PermissionProvider'; const DIVIDER_SX = { height: '1px', @@ -11,19 +14,6 @@ const DIVIDER_SX = { my: 1.25 }; -export interface Key { - // 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'; export type MissingPermissionReason = { @@ -43,7 +33,7 @@ export type MissingCapabilityReason = { export type ReasonEvent = MissingPermissionReason | MissingCapabilityReason; export interface HasKeyProps { - Key?: Key; + Key?: Key & { action?: string; subject?: string }; predicate?: (capabilitiesRegistry: unknown) => [boolean, ReasonEvent]; children: React.ReactNode; notifyOnclick?: boolean; @@ -74,6 +64,7 @@ export const PermissionShield: React.FC = ({ const [open, setOpen] = React.useState(false); const [copied, setCopied] = React.useState(false); const uniqueId = React.useId(); + const userContext = usePermissionUserContext(); const handleClose = () => { setOpen(false); @@ -147,7 +138,7 @@ export const PermissionShield: React.FC = ({ component="span" onClick={(e: React.MouseEvent) => { e.stopPropagation(); - navigator.clipboard.writeText(permissionKey.id || permissionKey.action || ''); + navigator.clipboard.writeText(permissionKey.id || ''); setCopied(true); setTimeout(() => setCopied(false), 1500); }} @@ -172,7 +163,7 @@ export const PermissionShield: React.FC = ({ color: '#FFFFFF' }} > - {permissionKey.function || permissionKey.subject || 'Access Restricted'} + {permissionKey.function || 'Access Restricted'} @@ -187,7 +178,7 @@ export const PermissionShield: React.FC = ({ }} > {permissionKey.description || - `Allows you to perform the ${permissionKey.function || permissionKey.subject || 'selected'} operation.`} + `Allows you to perform the ${permissionKey.function || 'selected'} operation.`} @@ -264,100 +255,84 @@ export const PermissionShield: React.FC = ({ - {/* User / Org / Role context — read from sessionStorage */} - {(() => { - try { - const org = JSON.parse(sessionStorage.getItem('currentOrg') || 'null'); - const user = JSON.parse(sessionStorage.getItem('user') || 'null'); - const orgName = org?.name; - const firstName = user?.firstName || user?.first_name || ''; - const lastName = user?.lastName || user?.last_name || ''; - const userName = `${firstName} ${lastName}`.trim() || user?.name || user?.email; - const orgId = org?.id; - const orgWithRoles = user?.organizations?.organizationsWithRoles?.find( - (o: { id?: string; roleNames?: string[] }) => o.id === orgId - ); - const roleNames: string[] = orgWithRoles?.roleNames || user?.roleNames || []; - - if (!orgName && !userName) return null; - - return ( - <> - - + + + {userContext.userName && ( + + + User + + + {userContext.userName} + + + )} + + + Org + + - - - User - - - {userName} - - - - - Org - - - {orgName || 'Private Org'} - - - - - Role(s) - - - {roleNames.length > 0 ? roleNames.join(', ') : 'None'} - - - + {userContext.orgName || 'Private Org'} + + + + + Role(s) + - Seeing this message in error? Contact your Admins to request access. + {userContext.roleNames && userContext.roleNames.length > 0 + ? userContext.roleNames.join(', ') + : 'None'} - - ); - } catch { - return null; - } - })()} + + + + Seeing this message in error? Contact your Admins to request access. + + + )} ); @@ -539,3 +514,17 @@ export const createCanShow = ( ); }; }; + +// Re-export PermissionProvider types and hooks +export { + PermissionProvider, + usePermission, + useHasPermission, + usePermissionUserContext +} from './PermissionProvider'; +export type { + PermissionAction, + PermissionProviderValue, + PermissionUserContext, + PermissionProviderProps +} from './PermissionProvider'; diff --git a/src/index.tsx b/src/index.tsx index 1129de512..5eff2e6ab 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -47,7 +47,15 @@ export { } from './custom/UniversalFilter'; export { + PermissionProvider, PermissionShield, + usePermission, + useHasPermission, + usePermissionUserContext, type Key, - type PermissionShieldProps + type PermissionAction, + type PermissionProviderProps, + type PermissionProviderValue, + type PermissionShieldProps, + type PermissionUserContext } from './custom/permissions';