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:
- ProtectedButton — Auto-disables button + shows tooltip when access denied
- ProtectedMenuItem — Auto-hides or disables menu items based on permission
- 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
componentprop 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
disabledprop:disabled={!hasAccess || props.disabled} - Wrap in
<span>when disabled for MUI Tooltip compatibility - Use
useHasAccesshook 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
componentprop 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
useHasAccesshook 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
childrentakes precedence — if provided, ignoreiconandmessageicon={null}explicitly hides icon (vsicon={undefined}uses default)message={null}explicitly hides messagevariant="inline"(default): Horizontal row, compactvariant="alert": Uses alert componentvariant="empty-state": Centered with more padding- Use
Lockicon from@mui/icons-materialas default - Use theme colors for consistency
Testing Strategy
Unit Tests (Vitest)
ProtectedButton.spec.tsx:
- Renders enabled button when
hasAccessreturns true - Renders disabled button when
hasAccessreturns 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
disabledprop (both access + prop must allow) - Works with
componentprop (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
componentprop (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
messagestring overrides default - Custom
messageReactNode renders correctly - Custom
iconprop overrides default Lock icon childrenbypasses 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 tooltipCustomTooltip— Custom text tooltipRichTooltip— ReactNode tooltip with StackNoTooltip— Disabled without tooltipAsLink— Usingcomponent={Link}polymorphismWithStartIcon— Button with iconMergedDisabled— Combined access + disabled prop
ProtectedIconButton.stories.tsx:
Default— With accessNoAccess— Disabled with tooltipCustomTooltip— Custom tooltipDifferentSizes— Small, medium, large
ProtectedMenuItem.stories.tsx:
Default— With accessHiddenNoAccess— Not in DOM (default behavior)DisabledNoAccess— Grayed with tooltipDisabledCustomTooltip— Custom tooltip textDisabledRichTooltip— ReactNode tooltipDisabledNoTooltip— No tooltipWithIcons— MenuItem with ListItemIcon/ListItemTextAsLink— MenuItem as Link
NoAccessMessage.stories.tsx:
Default— Icon + default messageIconOnly— Just lock iconMessageOnly— Just message textCustomMessageString— Custom message stringCustomMessageReactNode— Message as TypographyCustomIcon— Different iconCustomChildren— Full custom Stack layoutInlineVariant— Compact horizontalAlertVariant— Alert styleEmptyStateVariant— Centered layout
Export Updates
packages/core/src/core.ts
Add exports:
export * from "./ProtectedButton";
export * from "./ProtectedMenuItem";
export * from "./NoAccessMessage";
Implementation Order
- ProtectedButton — Most commonly needed, establishes pattern
- ProtectedMenuItem — Builds on similar pattern
- NoAccessMessage — Standalone, can be done in parallel
- 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
-
componentprop polymorphism works (e.g.,component={Link}) -
refforwarding 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 testpasses -
yarn lintpasses -
yarn typecheckpasses