Skip to main content

Sample Implementation Plan: RBAC Permission Enforcement Components

12 min read
claude codeplanningreference
Khmer decorative icon
Khmer decorative icon

This is the implementation plan referenced in "How Planning Became the Highest-Leverage Part of Our Engineering Process". It was derived from the collaborative team plan on remaining RBAC work, built in a Claude Code planning session, and submitted as a pull request for engineer review before any code was written.


Overview

Create three reusable components for consistent RBAC permission enforcement across the UI:

  1. ProtectedButton — Auto-disables button + shows tooltip when access denied
  2. ProtectedMenuItem — Auto-hides or disables menu items based on permission
  3. NoAccessMessage — Consistent messaging for access denial

Component 1: ProtectedButton

Location

packages/core/src/ProtectedButton/

Files (Pod Pattern)

ProtectedButton/
├── index.ts
├── ProtectedButton.tsx
├── ProtectedButton.types.ts
├── ProtectedButton.spec.tsx
├── ProtectedButton.stories.tsx
├── ProtectedIconButton.tsx
├── ProtectedIconButton.types.ts
├── ProtectedIconButton.spec.tsx
└── ProtectedIconButton.stories.tsx

Design Philosophy (MUI-Idiomatic)

Component IS a protected button (not a wrapper)

  • Extends MUI Button with access control built-in
  • Uses MUI's component prop for polymorphism (render as Link, etc.)
  • Follows MUI Tooltip + disabled pattern (span wrapper)
  • Familiar API for MUI users

API Design

// Extends MUI's ButtonProps with access control
interface ProtectedButtonProps<C extends React.ElementType = "button">
  extends ButtonProps<C, { component?: C }> {
  /** Access predicate - button disabled when access denied */
  hasAccess?: AccessPredicate;
  /** Tooltip content when disabled. Default: "You don't have permission". */
  accessDeniedTooltip?: ReactNode | null;
}

Usage Examples

// 1. Standard Button (simplest case)
<ProtectedButton hasAccess={canManageItems} variant="contained" onClick={handleCreate}>
  Create Item
</ProtectedButton>

// 2. Button as Link (MUI polymorphism)
<ProtectedButton
  hasAccess={canEditItems}
  component={Link}
  to="/items/edit"
  variant="outlined"
>
  Edit Item
</ProtectedButton>

// 3. Different variants
<ProtectedButton hasAccess={canDelete} color="error" startIcon={<DeleteIcon />}>
  Delete
</ProtectedButton>

// 4. Custom tooltip message
<ProtectedButton
  hasAccess={canPublish}
  accessDeniedTooltip="You need Publish permission"
  variant="contained"
>
  Publish
</ProtectedButton>

// 5. Rich tooltip content
<ProtectedButton
  hasAccess={canManageItems}
  accessDeniedTooltip={
    <Stack spacing={0.5}>
      <Typography variant="body2" fontWeight="medium">Permission Required</Typography>
      <Typography variant="caption">Contact your administrator</Typography>
    </Stack>
  }
>
  Create Item
</ProtectedButton>

// 6. No tooltip (just disabled)
<ProtectedButton hasAccess={canManageItems} accessDeniedTooltip={null}>
  Create
</ProtectedButton>

// 7. Combined with other disabled logic
<ProtectedButton hasAccess={canSave} disabled={!isFormValid}>
  Save
</ProtectedButton>

For IconButton: ProtectedIconButton

Since IconButton has a different API, create a separate component:

interface ProtectedIconButtonProps extends IconButtonProps {
  hasAccess?: AccessPredicate;
  accessDeniedTooltip?: ReactNode | null;
}

// Usage
<ProtectedIconButton hasAccess={canDelete} onClick={handleDelete} aria-label="delete">
  <DeleteIcon />
</ProtectedIconButton>

Implementation Notes

  • Extend MUI's ButtonProps using OverridableComponent pattern
  • Merge disabled prop: disabled={!hasAccess || props.disabled}
  • Wrap in <span> when disabled for MUI Tooltip compatibility
  • Use useHasAccess hook from shared package
  • Forward ref properly for MUI polymorphism
// Implementation concept
export const ProtectedButton = forwardRef<HTMLButtonElement, ProtectedButtonProps>(
  ({ hasAccess, accessDeniedTooltip, disabled, ...props }, ref) => {
    const canAccess = useHasAccess(hasAccess);
    const isDisabled = !canAccess || disabled;
    const tooltip = accessDeniedTooltip ?? "You don't have permission";

    const button = <Button ref={ref} disabled={isDisabled} {...props} />;

    // MUI pattern: wrap disabled button in span for tooltip
    if (!canAccess && accessDeniedTooltip !== null) {
      return (
        <Tooltip title={tooltip}>
          <Box component="span" sx={{ display: "inline-block" }}>
            {button}
          </Box>
        </Tooltip>
      );
    }

    return button;
  }
);

Component 2: ProtectedMenuItem

Location

packages/core/src/ProtectedMenuItem/

Files (Pod Pattern)

ProtectedMenuItem/
├── index.ts
├── ProtectedMenuItem.tsx
├── ProtectedMenuItem.types.ts
├── ProtectedMenuItem.spec.tsx
└── ProtectedMenuItem.stories.tsx

Design Philosophy (MUI-Idiomatic)

Component IS a protected menu item (not a wrapper)

  • Extends MUI MenuItem with access control built-in
  • Uses MUI's component prop for polymorphism (render as Link, etc.)
  • Two modes: hide (default) or disable with tooltip

API Design

interface ProtectedMenuItemProps<C extends React.ElementType = typeof MenuItem>
  extends MenuItemProps<C, { component?: C }> {
  /** Access predicate */
  hasAccess?: AccessPredicate;
  /** Behavior when access denied. Default: "hide" */
  noAccessBehavior?: "hide" | "disable";
  /** Tooltip when disabled. Default: "You don't have permission". */
  accessDeniedTooltip?: ReactNode | null;
}

Usage Examples

// 1. Hide when no access (default, most common)
<ProtectedMenuItem hasAccess={canDeleteItems} onClick={handleDelete}>
  Delete
</ProtectedMenuItem>
// No access = not rendered at all

// 2. Disable with default tooltip
<ProtectedMenuItem hasAccess={canDeleteItems} noAccessBehavior="disable">
  Delete
</ProtectedMenuItem>

// 3. Menu item with icon
<ProtectedMenuItem hasAccess={canDelete} noAccessBehavior="disable">
  <ListItemIcon><DeleteIcon /></ListItemIcon>
  <ListItemText>Delete</ListItemText>
</ProtectedMenuItem>

// 4. Menu item as Link (MUI polymorphism)
<ProtectedMenuItem
  hasAccess={canEditItems}
  component={Link}
  to="/items/edit"
>
  <ListItemIcon><EditIcon /></ListItemIcon>
  <ListItemText>Edit</ListItemText>
</ProtectedMenuItem>

// 5. Custom tooltip
<ProtectedMenuItem
  hasAccess={canDeleteItems}
  noAccessBehavior="disable"
  accessDeniedTooltip="Contact admin to enable delete"
>
  Delete
</ProtectedMenuItem>

// 6. Rich tooltip content
<ProtectedMenuItem
  hasAccess={canDeleteItems}
  noAccessBehavior="disable"
  accessDeniedTooltip={
    <Stack>
      <Typography variant="body2">Cannot Delete</Typography>
      <Typography variant="caption">This item is in use</Typography>
    </Stack>
  }
>
  <ListItemIcon><DeleteIcon /></ListItemIcon>
  <ListItemText>Delete</ListItemText>
</ProtectedMenuItem>

// 7. No tooltip (just disabled)
<ProtectedMenuItem
  hasAccess={canDelete}
  noAccessBehavior="disable"
  accessDeniedTooltip={null}
>
  Delete
</ProtectedMenuItem>

Implementation Notes

  • Extend MUI's MenuItemProps using OverridableComponent pattern
  • Default noAccessBehavior="hide" (matches existing patterns)
  • When hidden: return null (not in DOM)
  • When disabled: render MenuItem with disabled, wrap with Tooltip
  • Tooltip wrapper uses <Box component="span"> for disabled support
  • Use useHasAccess hook from shared package
  • Forward ref properly for MUI polymorphism
// Implementation concept
export const ProtectedMenuItem = forwardRef<HTMLLIElement, ProtectedMenuItemProps>(
  ({ hasAccess, noAccessBehavior = "hide", accessDeniedTooltip, disabled, ...props }, ref) => {
    const canAccess = useHasAccess(hasAccess);

    // Hide mode: don't render if no access
    if (!canAccess && noAccessBehavior === "hide") {
      return null;
    }

    const isDisabled = !canAccess || disabled;
    const tooltip = accessDeniedTooltip ?? "You don't have permission";

    const menuItem = <MenuItem ref={ref} disabled={isDisabled} {...props} />;

    // Disable mode: show with tooltip
    if (!canAccess && noAccessBehavior === "disable" && accessDeniedTooltip !== null) {
      return (
        <Tooltip title={tooltip} placement="left">
          <Box component="span">{menuItem}</Box>
        </Tooltip>
      );
    }

    return menuItem;
  }
);

Component 3: NoAccessMessage

Location

packages/core/src/NoAccessMessage/

Files (Pod Pattern)

NoAccessMessage/
├── index.ts
├── NoAccessMessage.tsx
├── NoAccessMessage.types.ts
├── NoAccessMessage.spec.tsx
└── NoAccessMessage.stories.tsx

Design Philosophy

Smart defaults + children escape hatch

  • Most common case requires zero props
  • Customize individual parts via props
  • Full control via children (bypasses all defaults)

API Design

interface NoAccessMessageProps {
  /** Icon element. Default: Lock icon. Set to `null` to hide. */
  icon?: ReactNode | null;
  /** Message text. Default: "Contact admin for access". Set to `null` to hide. */
  message?: ReactNode | null;
  /** Layout variant */
  variant?: "inline" | "alert" | "empty-state";
  /** Children bypass all defaults - render exactly what's passed */
  children?: ReactNode;
}

Usage Examples

// 1. Default: Icon + default message
<NoAccessMessage />

// 2. Just icon (compact)
<NoAccessMessage message={null} />

// 3. Just message (no icon)
<NoAccessMessage icon={null} />

// 4. Custom message
<NoAccessMessage message="You need Manage permission" />

// 5. Custom icon + message
<NoAccessMessage icon={<ShieldIcon />} message="Admin access required" />

// 6. Full custom (children escape hatch)
<NoAccessMessage>
  <Stack direction="row" spacing={1}>
    <WarningIcon />
    <Box>
      <Typography variant="subtitle2">Access Denied</Typography>
      <Typography variant="caption">Contact support@company.com</Typography>
    </Box>
  </Stack>
</NoAccessMessage>

// 7. Alert variant (for prominent warnings)
<NoAccessMessage variant="alert" message="You don't have permission to view this data" />

// 8. Empty state variant (centered, for empty lists)
<NoAccessMessage variant="empty-state" />

Implementation Notes

  • children takes precedence — if provided, ignore icon and message
  • icon={null} explicitly hides icon (vs icon={undefined} uses default)
  • message={null} explicitly hides message
  • variant="inline" (default): Horizontal row, compact
  • variant="alert": Uses alert component
  • variant="empty-state": Centered with more padding
  • Use Lock icon from @mui/icons-material as default
  • Use theme colors for consistency

Testing Strategy

Unit Tests (Vitest)

ProtectedButton.spec.tsx:

  • Renders enabled button when hasAccess returns true
  • Renders disabled button when hasAccess returns false
  • Shows default tooltip on hover when disabled due to access
  • Shows custom string tooltip
  • Shows custom ReactNode tooltip
  • No tooltip when accessDeniedTooltip={null}
  • Merges disabled prop (both access + prop must allow)
  • Works with component prop (renders as Link, etc.)
  • Forwards ref correctly
  • Handles undefined hasAccess (defaults to enabled)

ProtectedIconButton.spec.tsx:

  • Same test cases as ProtectedButton
  • Properly handles IconButton-specific props

ProtectedMenuItem.spec.tsx:

  • Returns null when access denied and noAccessBehavior="hide" (default)
  • Renders disabled MenuItem when noAccessBehavior="disable"
  • Shows tooltip when disabled in disable mode
  • No tooltip when accessDeniedTooltip={null}
  • Renders normally when access granted
  • Works with component prop (renders as Link)
  • Supports ListItemIcon and ListItemText children
  • Forwards ref correctly

NoAccessMessage.spec.tsx:

  • Renders icon + default message by default
  • Renders only icon when message={null}
  • Renders only message when icon={null}
  • Custom message string overrides default
  • Custom message ReactNode renders correctly
  • Custom icon prop overrides default Lock icon
  • children bypasses icon and message entirely
  • Each variant renders correctly (inline, alert, empty-state)

Storybook Stories

ProtectedButton.stories.tsx:

  • Default — With access, all variants (contained, outlined, text)
  • NoAccess — Disabled with default tooltip
  • CustomTooltip — Custom text tooltip
  • RichTooltip — ReactNode tooltip with Stack
  • NoTooltip — Disabled without tooltip
  • AsLink — Using component={Link} polymorphism
  • WithStartIcon — Button with icon
  • MergedDisabled — Combined access + disabled prop

ProtectedIconButton.stories.tsx:

  • Default — With access
  • NoAccess — Disabled with tooltip
  • CustomTooltip — Custom tooltip
  • DifferentSizes — Small, medium, large

ProtectedMenuItem.stories.tsx:

  • Default — With access
  • HiddenNoAccess — Not in DOM (default behavior)
  • DisabledNoAccess — Grayed with tooltip
  • DisabledCustomTooltip — Custom tooltip text
  • DisabledRichTooltip — ReactNode tooltip
  • DisabledNoTooltip — No tooltip
  • WithIcons — MenuItem with ListItemIcon/ListItemText
  • AsLink — MenuItem as Link

NoAccessMessage.stories.tsx:

  • Default — Icon + default message
  • IconOnly — Just lock icon
  • MessageOnly — Just message text
  • CustomMessageString — Custom message string
  • CustomMessageReactNode — Message as Typography
  • CustomIcon — Different icon
  • CustomChildren — Full custom Stack layout
  • InlineVariant — Compact horizontal
  • AlertVariant — Alert style
  • EmptyStateVariant — Centered layout

Export Updates

packages/core/src/core.ts

Add exports:

export * from "./ProtectedButton";
export * from "./ProtectedMenuItem";
export * from "./NoAccessMessage";

Implementation Order

  1. ProtectedButton — Most commonly needed, establishes pattern
  2. ProtectedMenuItem — Builds on similar pattern
  3. NoAccessMessage — Standalone, can be done in parallel
  4. Export updates — Quick update, can be done last

Verification Checklist

  • All components follow pod pattern (index, .tsx, .types.ts, .spec.tsx, .stories.tsx)
  • ProtectedButton extends MUI ButtonProps correctly
  • ProtectedIconButton extends MUI IconButtonProps correctly
  • ProtectedMenuItem extends MUI MenuItemProps correctly
  • component prop polymorphism works (e.g., component={Link})
  • ref forwarding works correctly
  • All components have Storybook stories covering all use cases
  • All components have unit tests with mocked access control
  • No hardcoded colors or pixel values (use theme)
  • No raw HTML elements (use MUI Box, Typography, etc.)
  • Tooltip follows MUI pattern (span wrapper for disabled)
  • Exports added to packages/core/src/core.ts
  • yarn test passes
  • yarn lint passes
  • yarn typecheck passes

Related Posts

Khmer decorative icon

Questions about this post?

Ask Pai — Pros's AI colleague can discuss this article and connect it to his other work.

Khmer decorative icon