Converge RBAC settings actions on shared Button primitives

This commit is contained in:
rcourtman
2026-06-13 22:31:23 +01:00
parent a418b8da27
commit 418cf1648d
7 changed files with 119 additions and 18 deletions
@@ -576,6 +576,11 @@ not a replacement status card, CTA band, or page-local nested card.
security/privacy owns auth setup, password-change, credential-rotation, and
read-only semantics, while `Button` owns the warning, primary, secondary,
and settings-action chrome.
Organization RBAC settings actions follow the same boundary: organization
settings owns role creation, role editing/deletion, user-access assignment,
feature-gate, and row-action semantics, while `Button` / `ActionIconButton`
own primary, ghost, accent, danger, focus, disabled, and settings-action
chrome.
If a new surface needs a variant that the shared primitive does not expose,
extend the primitive and registry guard rather than adding a page-local
class string.
@@ -124,6 +124,11 @@ create, review, and approve cross-organization shares.
`LoadingSpinner` primitive. Organization settings owns RBAC loading
semantics; frontend-primitives owns spinner size, tone, and accessible
status behavior.
9. Keep RBAC role creation, role edit/delete row actions, and user-access
management actions on the shared `Button` / `ActionIconButton` primitives.
Organization settings owns the role and assignment semantics, feature-gate
availability, and row-action behavior; frontend-primitives owns primary,
ghost, accent, danger, focus, disabled, and settings-action chrome.
## Current State
@@ -969,9 +969,11 @@
{ "path": "src/components/Settings/ProLicensePlanSection.tsx" },
{ "path": "src/components/Settings/ReportingPanel.tsx" },
{ "path": "src/components/Settings/ResourcePicker.tsx" },
{ "path": "src/components/Settings/RolesPanel.tsx" },
{ "path": "src/components/Settings/SelfHostedCommercialRecoverySection.tsx" },
{ "path": "src/components/Settings/SecurityAuthPanel.tsx" },
{ "path": "src/components/Settings/SSOProvidersPanel.tsx" },
{ "path": "src/components/Settings/UserAssignmentsPanel.tsx" },
{ "path": "src/components/UpdateConfirmationModal.tsx" },
{ "path": "src/components/UpdateProgressModal.tsx" },
{ "path": "src/components/Workloads/GuestDrawer.tsx" },
@@ -1011,6 +1013,12 @@
"w-full sm:w-auto min-h-10 sm:min-h-9 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm rounded-md border border-border"
]
},
{
"path": "src/components/Settings/RolesPanel.tsx",
"patterns": [
"inline-flex w-full sm:w-auto min-h-10 sm:min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
]
},
{
"path": "src/components/Settings/ReportingPanel.tsx",
"patterns": [
@@ -1060,6 +1068,12 @@
"px-2 py-1 text-xs font-medium text-muted bg-surface-hover rounded hover:bg-surface-hover flex items-center gap-1"
]
},
{
"path": "src/components/Settings/UserAssignmentsPanel.tsx",
"patterns": [
"inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
]
},
{
"path": "src/components/Settings/InfrastructureInstallerSection.tsx",
"patterns": [
@@ -1172,6 +1186,7 @@
{ "path": "src/components/Alerts/AlertResourceTableMobile.tsx" },
{ "path": "src/components/Alerts/AlertResourceTableRow.tsx" },
{ "path": "src/components/Alerts/ResourceTable.tsx" },
{ "path": "src/components/Settings/RolesPanel.tsx" },
{ "path": "src/components/Settings/SSOProvidersPanel.tsx" }
],
"forbiddenPatterns": [
@@ -1211,6 +1226,13 @@
"p-2 text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900 rounded-md transition-colors",
"class=\"text-slate-400 hover:text-base-content\""
]
},
{
"path": "src/components/Settings/RolesPanel.tsx",
"patterns": [
"p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300",
"p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
]
}
],
"proof": [
@@ -1,4 +1,5 @@
import { Component, For, Show } from 'solid-js';
import { ActionIconButton, Button } from '@/components/shared/Button';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { RBACFeatureGateSection } from './RBACFeatureGateSection';
import { RolesEditorDialog } from './RolesEditorDialog';
@@ -19,15 +20,16 @@ export const RolesPanel: Component = () => {
<SettingsPanel
title="Roles"
action={
<button
type="button"
<Button
variant="primary"
size="settingsAction"
class="w-full gap-2 sm:w-auto"
onClick={state.openCreateRole}
disabled={!state.featureGate.rbacEnabled()}
class="inline-flex w-full sm:w-auto min-h-10 sm:min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<Plus class="w-4 h-4" />
New Role
</button>
</Button>
}
noPadding
bodyClass="divide-y divide-border"
@@ -87,22 +89,22 @@ export const RolesPanel: Component = () => {
render: (role) => (
<div class="inline-flex items-center gap-1">
<Show when={!role.isBuiltIn}>
<button
type="button"
<ActionIconButton
label="Edit role"
tone="accent"
size="sm"
onClick={() => state.openEditRole(role)}
class="p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300"
title="Edit role"
>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
</ActionIconButton>
<ActionIconButton
label="Delete role"
tone="danger"
size="sm"
onClick={() => state.handleDeleteRole(role)}
class="p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
title="Delete role"
>
<Trash2 class="w-4 h-4" />
</button>
</ActionIconButton>
</Show>
<Show when={role.isBuiltIn}>
<span class="text-xs text-slate-400 italic">Read-only</span>
@@ -1,4 +1,5 @@
import { Component, For, Show } from 'solid-js';
import { Button } from '@/components/shared/Button';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { RBACFeatureGateSection } from './RBACFeatureGateSection';
import { UserAssignmentsDialog } from './UserAssignmentsDialog';
@@ -109,14 +110,15 @@ export const UserAssignmentsPanel: Component = () => {
label: 'Actions',
align: 'right',
render: (assignment) => (
<button
type="button"
<Button
variant="ghost"
size="settingsAction"
class="gap-2"
onClick={() => state.openManageAccess(assignment)}
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
>
<Pencil class="w-4 h-4" />
Manage Access
</button>
</Button>
),
},
]}
@@ -66,6 +66,8 @@ import organizationAccessManagementSectionSource from '../OrganizationAccessMana
import organizationAccessMembersSectionSource from '../OrganizationAccessMembersSection.tsx?raw';
import organizationSharingCreateSectionSource from '../OrganizationSharingCreateSection.tsx?raw';
import rolesEditorDialogSource from '../RolesEditorDialog.tsx?raw';
import rolesPanelSource from '../RolesPanel.tsx?raw';
import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw';
import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw';
import diagnosticsModelSource from '../diagnosticsModel.ts?raw';
import agentProfilesStateSource from '../useAgentProfilesPanelState.ts?raw';
@@ -343,6 +345,26 @@ describe('settings architecture guardrails', () => {
expect(securityAuthPanelSource).not.toContain(
'w-full sm:w-auto min-h-10 sm:min-h-10 px-4 py-2.5 text-sm font-medium border border-border',
);
expect(rolesPanelSource).toContain(
"import { ActionIconButton, Button } from '@/components/shared/Button';",
);
expect(rolesPanelSource).toContain('variant="primary"');
expect(rolesPanelSource).toContain('ActionIconButton');
expect(rolesPanelSource).not.toContain(
'inline-flex w-full sm:w-auto min-h-10 sm:min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600',
);
expect(rolesPanelSource).not.toContain('p-1.5 rounded-md text-slate-500 hover:text-blue-600');
expect(rolesPanelSource).not.toContain('p-1.5 rounded-md text-slate-500 hover:text-red-600');
expect(userAssignmentsPanelSource).toContain(
"import { Button } from '@/components/shared/Button';",
);
expect(userAssignmentsPanelSource).toContain('variant="ghost"');
expect(userAssignmentsPanelSource).toContain('size="settingsAction"');
expect(userAssignmentsPanelSource).not.toContain(
'inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md',
);
});
it('keeps settings callouts on the shared CalloutCard primitive', () => {
@@ -3097,9 +3097,11 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/ProLicensePlanSection.tsx',
'src/components/Settings/ReportingPanel.tsx',
'src/components/Settings/ResourcePicker.tsx',
'src/components/Settings/RolesPanel.tsx',
'src/components/Settings/SelfHostedCommercialRecoverySection.tsx',
'src/components/Settings/SecurityAuthPanel.tsx',
'src/components/Settings/SSOProvidersPanel.tsx',
'src/components/Settings/UserAssignmentsPanel.tsx',
'src/components/UpdateConfirmationModal.tsx',
'src/components/UpdateProgressModal.tsx',
'src/components/Workloads/GuestDrawer.tsx',
@@ -3140,6 +3142,12 @@ describe('shared primitive guardrails', () => {
'w-full sm:w-auto min-h-10 sm:min-h-9 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm rounded-md border border-border',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/RolesPanel.tsx',
patterns: expect.arrayContaining([
'inline-flex w-full sm:w-auto min-h-10 sm:min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/ReportingPanel.tsx',
patterns: expect.arrayContaining([
@@ -3186,6 +3194,12 @@ describe('shared primitive guardrails', () => {
'px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-md hover:bg-red-700',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/UserAssignmentsPanel.tsx',
patterns: expect.arrayContaining([
'inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/InfrastructureInstallerSection.tsx',
patterns: expect.arrayContaining([
@@ -3593,6 +3607,7 @@ describe('shared primitive guardrails', () => {
'src/components/Alerts/AlertResourceTableMobile.tsx',
'src/components/Alerts/AlertResourceTableRow.tsx',
'src/components/Alerts/ResourceTable.tsx',
'src/components/Settings/RolesPanel.tsx',
'src/components/Settings/SSOProvidersPanel.tsx',
]);
expect(actionIconRule?.forbiddenPatterns).toEqual(
@@ -3638,6 +3653,13 @@ describe('shared primitive guardrails', () => {
'class="text-slate-400 hover:text-base-content"',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/RolesPanel.tsx',
patterns: expect.arrayContaining([
'p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300',
'p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900',
]),
}),
]),
);
expect(alertResourceActionGuard?.canonical?.path).toBe('src/components/shared/Button.tsx');
@@ -3911,6 +3933,20 @@ describe('shared primitive guardrails', () => {
expect(resourcePickerSource).not.toContain(
'w-full sm:w-auto min-h-10 sm:min-h-9 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm rounded-md border border-border',
);
expect(rolesPanelSource).toContain('@/components/shared/Button');
expect(rolesPanelSource).toContain('<Button');
expect(rolesPanelSource).toContain('ActionIconButton');
expect(rolesPanelSource).toContain('variant="primary"');
expect(rolesPanelSource).toContain('size="settingsAction"');
expect(rolesPanelSource).not.toContain(
'inline-flex w-full sm:w-auto min-h-10 sm:min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700',
);
expect(rolesPanelSource).not.toContain(
'p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300',
);
expect(rolesPanelSource).not.toContain(
'p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900',
);
expect(reportingPanelSource).toContain('@/components/shared/Button');
expect(reportingPanelSource).toContain('<Button');
expect(reportingPanelSource).toContain('variant="success"');
@@ -3979,6 +4015,13 @@ describe('shared primitive guardrails', () => {
expect(securityAuthPanelSource).not.toContain(
'w-full sm:w-auto min-h-10 sm:min-h-10 px-4 py-2.5 text-sm font-medium border border-border text-base-content rounded-md hover:bg-surface-hover transition-colors',
);
expect(userAssignmentsPanelSource).toContain('@/components/shared/Button');
expect(userAssignmentsPanelSource).toContain('<Button');
expect(userAssignmentsPanelSource).toContain('variant="ghost"');
expect(userAssignmentsPanelSource).toContain('size="settingsAction"');
expect(userAssignmentsPanelSource).not.toContain(
'inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors',
);
expect(discoveryTabSource).toContain('@/components/shared/Button');
expect(discoveryTabSource).toContain('@/components/shared/CopyableCodeRow');
expect(discoveryTabSource).toContain('CopyValueButton');