Split RBAC settings runtime owners

This commit is contained in:
rcourtman 2026-03-21 01:20:44 +00:00
parent c6f627dc2f
commit b486ac05cd
14 changed files with 1004 additions and 743 deletions

View file

@ -28,10 +28,16 @@ create cross-organization shares.
3. `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`
4. `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`
5. `frontend-modern/src/components/Settings/RolesPanel.tsx`
6. `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`
7. `frontend-modern/src/utils/orgUtils.ts`
8. `frontend-modern/src/utils/organizationRolePresentation.ts`
9. `frontend-modern/src/utils/organizationSettingsPresentation.ts`
6. `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`
7. `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`
8. `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`
9. `frontend-modern/src/components/Settings/useRolesPanelState.ts`
10. `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`
11. `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`
12. `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts`
13. `frontend-modern/src/utils/orgUtils.ts`
14. `frontend-modern/src/utils/organizationRolePresentation.ts`
15. `frontend-modern/src/utils/organizationSettingsPresentation.ts`
## Shared Boundaries
@ -40,7 +46,7 @@ create cross-organization shares.
## Extension Points
1. Add or change organization role and share semantics through `internal/models/organization.go`
2. Add or change organization access, overview, sharing, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, and `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`
2. Add or change organization access, overview, sharing, RBAC feature-gating, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`, `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`, `frontend-modern/src/components/Settings/useRolesPanelState.ts`, `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`, `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`, and `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts`
3. Route organization transport changes through `frontend-modern/src/api/orgs.ts` and `frontend-modern/src/api/rbac.ts`
4. Keep backend organization and RBAC handler changes aligned through `internal/api/org_handlers.go`, `internal/api/org_lifecycle_handlers.go`, and `internal/api/enterprise_extension_rbac_admin.go`
@ -105,3 +111,11 @@ sharing views stay aligned with the shared multi-tenant org context contract.
The organization sharing surface now also sources resource quick-pick labels
from the shared preferred resource display helper, so governed resources do
not fall back to raw names inside share creation.
The RBAC settings area now follows the same extracted-owner pattern as the
other modernized settings surfaces: `RBACFeatureGateSection.tsx` owns the
shared paywall CTA rendering, `useRBACFeatureGateState.ts` owns the shared
license and free-trial runtime, `useRolesPanelState.ts` plus
`RolesEditorDialog.tsx` own the roles runtime split, and
`useUserAssignmentsPanelState.ts` plus `UserAssignmentsDialog.tsx` own the
user-assignment runtime split. `RolesPanel.tsx` and `UserAssignmentsPanel.tsx`
remain the canonical render shells for those governed RBAC surfaces.

View file

@ -2116,8 +2116,14 @@
"frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx",
"frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx",
"frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx",
"frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx",
"frontend-modern/src/components/Settings/RolesEditorDialog.tsx",
"frontend-modern/src/components/Settings/RolesPanel.tsx",
"frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx",
"frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx",
"frontend-modern/src/components/Settings/useRBACFeatureGateState.ts",
"frontend-modern/src/components/Settings/useRolesPanelState.ts",
"frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts",
"frontend-modern/src/utils/organizationRolePresentation.ts",
"frontend-modern/src/utils/organizationSettingsPresentation.ts",
"frontend-modern/src/utils/orgUtils.ts",
@ -2157,8 +2163,14 @@
"frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx",
"frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx",
"frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx",
"frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx",
"frontend-modern/src/components/Settings/RolesEditorDialog.tsx",
"frontend-modern/src/components/Settings/RolesPanel.tsx",
"frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx",
"frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx",
"frontend-modern/src/components/Settings/useRBACFeatureGateState.ts",
"frontend-modern/src/components/Settings/useRolesPanelState.ts",
"frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts",
"frontend-modern/src/utils/organizationRolePresentation.ts",
"frontend-modern/src/utils/organizationSettingsPresentation.ts",
"frontend-modern/src/utils/orgUtils.ts"

View file

@ -0,0 +1,51 @@
import { Component, Show } from 'solid-js';
import { getUpgradeActionUrlOrFallback } from '@/stores/license';
import type { RBACFeatureGateCopy } from '@/utils/rbacPresentation';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
UPGRADE_TRIAL_LABEL,
UPGRADE_TRIAL_LINK_CLASS,
} from '@/utils/upgradePresentation';
import type { RBACFeatureGateLocation } from './useRBACFeatureGateState';
interface RBACFeatureGateSectionProps {
canStartTrial: boolean;
copy: RBACFeatureGateCopy;
paywallLocation: RBACFeatureGateLocation;
startingTrial: boolean;
onStartTrial: () => void | Promise<void>;
}
export const RBACFeatureGateSection: Component<RBACFeatureGateSectionProps> = (props) => (
<div class="bg-surface-alt p-4 sm:p-6 transition-colors border-b border-border-subtle">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1 text-center sm:text-left">
<h4 class="text-base font-semibold text-base-content">{props.copy.title}</h4>
<p class="text-sm text-muted mt-1">{props.copy.body}</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<a
href={getUpgradeActionUrlOrFallback('rbac')}
target="_blank"
rel="noopener noreferrer"
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked(props.paywallLocation, 'rbac')}
>
{UPGRADE_ACTION_LABEL}
</a>
<Show when={props.canStartTrial}>
<button
type="button"
onClick={props.onStartTrial}
disabled={props.startingTrial}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</div>
</div>
);

View file

@ -0,0 +1,159 @@
import { Component, For, Show } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
import type { Permission, Role } from '@/types/rbac';
import { RBAC_PERMISSION_ACTIONS, RBAC_PERMISSION_RESOURCES } from '@/utils/rbacPermissions';
import Plus from 'lucide-solid/icons/plus';
import Trash2 from 'lucide-solid/icons/trash-2';
import X from 'lucide-solid/icons/x';
interface RolesEditorDialogProps {
editingRole: Role | null;
formDescription: string;
formId: string;
formName: string;
formPermissions: Permission[];
isOpen: boolean;
saving: boolean;
onAddPermission: () => void;
onClose: () => void;
onFormDescriptionInput: (value: string) => void;
onFormIdInput: (value: string) => void;
onFormNameInput: (value: string) => void;
onRemovePermission: (index: number) => void;
onSave: () => void;
onUpdatePermission: (index: number, field: keyof Permission, value: string) => void;
}
export const RolesEditorDialog: Component<RolesEditorDialogProps> = (props) => (
<Show when={props.isOpen}>
<Dialog
isOpen={true}
onClose={props.onClose}
panelClass="max-w-2xl"
closeOnBackdrop={false}
ariaLabel={props.editingRole ? 'Edit role' : 'New role'}
>
<div class="w-full max-h-[92vh] overflow-hidden">
<div class="flex items-start justify-between gap-3 px-4 sm:px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-base-content">
{props.editingRole ? 'Edit Role' : 'New Role'}
</h3>
<button
type="button"
onClick={props.onClose}
class="p-1.5 rounded-md text-slate-500 hover:text-base-content hover:bg-surface-hover"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="px-4 sm:px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Role ID</label>
<input
type="text"
value={props.formId}
onInput={(event) => props.onFormIdInput(event.currentTarget.value)}
placeholder="e.g., custom-auditor"
disabled={!!props.editingRole}
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900 disabled:opacity-50"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Role Name</label>
<input
type="text"
value={props.formName}
onInput={(event) => props.onFormNameInput(event.currentTarget.value)}
placeholder="e.g., Custom Auditor"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900"
/>
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Description</label>
<input
type="text"
value={props.formDescription}
onInput={(event) => props.onFormDescriptionInput(event.currentTarget.value)}
placeholder="Brief description of this role's purpose"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900"
/>
</div>
<div class="space-y-3 pt-2">
<div class="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<label class="block text-sm font-medium text-base-content">Permissions</label>
<button
type="button"
onClick={props.onAddPermission}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-300 flex items-center gap-1"
>
<Plus class="w-3 h-3" /> Add Permission
</button>
</div>
<div class="space-y-2">
<For each={props.formPermissions}>
{(permission, index) => (
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<select
value={permission.action}
onChange={(event) =>
props.onUpdatePermission(index(), 'action', event.currentTarget.value)
}
class="w-full sm:flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-base-content"
>
<For each={RBAC_PERMISSION_ACTIONS}>
{(action) => <option value={action}>{action}</option>}
</For>
</select>
<span class="hidden sm:inline text-slate-400 text-sm">:</span>
<select
value={permission.resource}
onChange={(event) =>
props.onUpdatePermission(index(), 'resource', event.currentTarget.value)
}
class="w-full sm:flex-1 rounded-md border bg-surface px-2 py-1.5 text-sm text-base-content"
>
<For each={RBAC_PERMISSION_RESOURCES}>
{(resource) => <option value={resource}>{resource}</option>}
</For>
</select>
<button
type="button"
onClick={() => props.onRemovePermission(index())}
disabled={props.formPermissions.length <= 1}
class="self-end sm:self-auto p-1.5 text-slate-400 hover:text-red-500 disabled:opacity-30"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
)}
</For>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:flex sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-4 border-t border-border">
<button
type="button"
onClick={props.onClose}
class="w-full sm:w-auto rounded-md px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover"
>
Cancel
</button>
<button
type="button"
onClick={props.onSave}
disabled={props.saving || !props.formName.trim()}
class="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{props.saving ? 'Saving...' : props.editingRole ? 'Update Role' : 'Create Role'}
</button>
</div>
</div>
</Dialog>
</Show>
);

View file

@ -1,216 +1,18 @@
import { Component, createEffect, createMemo, createSignal, onMount, Show, For } from 'solid-js';
import { Component, For, Show } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { RBACAPI } from '@/api/rbac';
import type { Role, Permission } from '@/types/rbac';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { Dialog } from '@/components/shared/Dialog';
import {
getUpgradeActionUrlOrFallback,
hasFeature,
loadLicenseStatus,
licenseLoaded,
startProTrial,
entitlements,
} from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
createDefaultRBACPermission,
RBAC_PERMISSION_ACTIONS,
RBAC_PERMISSION_RESOURCES,
} from '@/utils/rbacPermissions';
import {
getRBACFeatureGateCopy,
getRolesDeleteErrorMessage,
getRolesEmptyState,
getRolesLoadErrorMessage,
getRolesRequiredFieldsMessage,
getRolesSaveErrorMessage,
} from '@/utils/rbacPresentation';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
UPGRADE_TRIAL_LABEL,
UPGRADE_TRIAL_LINK_CLASS,
} from '@/utils/upgradePresentation';
import { RBACFeatureGateSection } from './RBACFeatureGateSection';
import { RolesEditorDialog } from './RolesEditorDialog';
import { useRolesPanelState } from './useRolesPanelState';
import { getRolesEmptyState } from '@/utils/rbacPresentation';
import Plus from 'lucide-solid/icons/plus';
import Pencil from 'lucide-solid/icons/pencil';
import Trash2 from 'lucide-solid/icons/trash-2';
import Shield from 'lucide-solid/icons/shield';
import BadgeCheck from 'lucide-solid/icons/badge-check';
import X from 'lucide-solid/icons/x';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
export const RolesPanel: Component = () => {
const [roles, setRoles] = createSignal<Role[]>([]);
const [loading, setLoading] = createSignal(true);
const [showModal, setShowModal] = createSignal(false);
const [editingRole, setEditingRole] = createSignal<Role | null>(null);
const [saving, setSaving] = createSignal(false);
const [startingTrial, setStartingTrial] = createSignal(false);
const featureGateCopy = () => getRBACFeatureGateCopy('roles');
const canStartTrial = () => entitlements()?.trial_eligible !== false;
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined),
);
}
} finally {
setStartingTrial(false);
}
};
// Form state
const [formId, setFormId] = createSignal('');
const [formName, setFormName] = createSignal('');
const [formDescription, setFormDescription] = createSignal('');
const [formPermissions, setFormPermissions] = createSignal<Permission[]>([]);
const rbacEnabled = createMemo(() => licenseLoaded() && hasFeature('rbac'));
const loadRoles = async () => {
if (!rbacEnabled()) {
setRoles([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await RBACAPI.getRoles();
setRoles(data || []);
} catch (err) {
if (err instanceof Error && /feature not included in license/i.test(err.message)) {
setRoles([]);
return;
}
logger.error('Failed to load roles', err);
notificationStore.error(getRolesLoadErrorMessage());
} finally {
setLoading(false);
}
};
onMount(() => {
void loadLicenseStatus();
});
createEffect(() => {
if (!licenseLoaded()) {
setLoading(true);
return;
}
if (!hasFeature('rbac')) {
setRoles([]);
setLoading(false);
return;
}
void loadRoles();
});
createEffect((wasPaywallVisible) => {
const isPaywallVisible = licenseLoaded() && !hasFeature('rbac') && !loading();
if (isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('rbac', 'settings_roles_panel');
}
return isPaywallVisible;
}, false);
const handleCreate = () => {
setEditingRole(null);
setFormId('');
setFormName('');
setFormDescription('');
setFormPermissions([createDefaultRBACPermission()]);
setShowModal(true);
};
const handleEdit = (role: Role) => {
if (role.isBuiltIn) return;
setEditingRole(role);
setFormId(role.id);
setFormName(role.name);
setFormDescription(role.description);
setFormPermissions([...role.permissions]);
setShowModal(true);
};
const handleDelete = async (role: Role) => {
if (role.isBuiltIn) return;
if (!confirm(`Are you sure you want to delete the role "${role.name}"?`)) return;
try {
await RBACAPI.deleteRole(role.id);
notificationStore.success(`Role "${role.name}" deleted`);
await loadRoles();
} catch (err) {
logger.error('Failed to delete role', err);
notificationStore.error(getRolesDeleteErrorMessage());
}
};
const handleSave = async () => {
const id = formId().trim().toLowerCase().replace(/\s+/g, '-');
const name = formName().trim();
if (!id || !name) {
notificationStore.error(getRolesRequiredFieldsMessage());
return;
}
setSaving(true);
try {
const role: Role = {
id,
name,
description: formDescription(),
permissions: formPermissions(),
createdAt: editingRole()?.createdAt,
};
await RBACAPI.saveRole(role);
notificationStore.success(`Role "${name}" saved`);
setShowModal(false);
await loadRoles();
} catch (err) {
logger.error('Failed to save role', err);
notificationStore.error(getRolesSaveErrorMessage());
} finally {
setSaving(false);
}
};
const addPermission = () => {
setFormPermissions([...formPermissions(), createDefaultRBACPermission()]);
};
const removePermission = (index: number) => {
const perms = [...formPermissions()];
perms.splice(index, 1);
setFormPermissions(perms);
};
const updatePermission = (index: number, field: keyof Permission, value: string) => {
const perms = [...formPermissions()];
perms[index] = { ...perms[index], [field]: value };
setFormPermissions(perms);
};
const state = useRolesPanelState();
return (
<div class="space-y-6">
@ -221,8 +23,8 @@ export const RolesPanel: Component = () => {
action={
<button
type="button"
onClick={handleCreate}
disabled={!rbacEnabled()}
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" />
@ -232,50 +34,26 @@ export const RolesPanel: Component = () => {
noPadding
bodyClass="divide-y divide-border"
>
<Show when={licenseLoaded() && !hasFeature('rbac') && !loading()}>
<div class="bg-surface-alt p-4 sm:p-6 transition-colors border-b border-border-subtle">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1 text-center sm:text-left">
<h4 class="text-base font-semibold text-base-content">{featureGateCopy().title}</h4>
<p class="text-sm text-muted mt-1">
{featureGateCopy().body}
</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<a
href={getUpgradeActionUrlOrFallback('rbac')}
target="_blank"
rel="noopener noreferrer"
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked('settings_roles_panel', 'rbac')}
>
{UPGRADE_ACTION_LABEL}
</a>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</div>
</div>
<Show when={state.featureGate.paywallVisible()}>
<RBACFeatureGateSection
canStartTrial={state.featureGate.canStartTrial()}
copy={state.featureGate.featureGateCopy()}
paywallLocation="settings_roles_panel"
startingTrial={state.featureGate.startingTrial()}
onStartTrial={state.featureGate.handleStartTrial}
/>
</Show>
<Show when={loading()}>
<Show when={state.loading()}>
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
</div>
</Show>
<Show when={!loading() && rbacEnabled()}>
<Show when={!state.loading() && state.featureGate.rbacEnabled()}>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={roles()}
data={state.roles()}
columns={[
{
key: 'role',
@ -316,7 +94,7 @@ export const RolesPanel: Component = () => {
<Show when={!role.isBuiltIn}>
<button
type="button"
onClick={() => handleEdit(role)}
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"
>
@ -324,7 +102,7 @@ export const RolesPanel: Component = () => {
</button>
<button
type="button"
onClick={() => handleDelete(role)}
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"
>
@ -347,138 +125,23 @@ export const RolesPanel: Component = () => {
</Show>
</SettingsPanel>
{/* Role Modal */}
<Show when={showModal()}>
<Dialog
isOpen={true}
onClose={() => setShowModal(false)}
panelClass="max-w-2xl"
closeOnBackdrop={false}
ariaLabel={editingRole() ? 'Edit role' : 'New role'}
>
<div class="w-full max-h-[92vh] overflow-hidden">
<div class="flex items-start justify-between gap-3 px-4 sm:px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-base-content">
{editingRole() ? 'Edit Role' : 'New Role'}
</h3>
<button
type="button"
onClick={() => setShowModal(false)}
class="p-1.5 rounded-md text-slate-500 hover:text-base-content hover:bg-surface-hover"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="px-4 sm:px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Role ID</label>
<input
type="text"
value={formId()}
onInput={(e) => setFormId(e.currentTarget.value)}
placeholder="e.g., custom-auditor"
disabled={!!editingRole()}
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900 disabled:opacity-50"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Role Name</label>
<input
type="text"
value={formName()}
onInput={(e) => setFormName(e.currentTarget.value)}
placeholder="e.g., Custom Auditor"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900"
/>
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-base-content">Description</label>
<input
type="text"
value={formDescription()}
onInput={(e) => setFormDescription(e.currentTarget.value)}
placeholder="Brief description of this role's purpose"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900"
/>
</div>
<div class="space-y-3 pt-2">
<div class="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<label class="block text-sm font-medium text-base-content">Permissions</label>
<button
type="button"
onClick={addPermission}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-300 flex items-center gap-1"
>
<Plus class="w-3 h-3" /> Add Permission
</button>
</div>
<div class="space-y-2">
<For each={formPermissions()}>
{(perm, index) => (
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<select
value={perm.action}
onChange={(e) =>
updatePermission(index(), 'action', e.currentTarget.value)
}
class="w-full sm:flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-base-content"
>
<For each={RBAC_PERMISSION_ACTIONS}>
{(action) => <option value={action}>{action}</option>}
</For>
</select>
<span class="hidden sm:inline text-slate-400 text-sm">:</span>
<select
value={perm.resource}
onChange={(e) =>
updatePermission(index(), 'resource', e.currentTarget.value)
}
class="w-full sm:flex-1 rounded-md border bg-surface px-2 py-1.5 text-sm text-base-content"
>
<For each={RBAC_PERMISSION_RESOURCES}>
{(resource) => <option value={resource}>{resource}</option>}
</For>
</select>
<button
type="button"
onClick={() => removePermission(index())}
disabled={formPermissions().length <= 1}
class="self-end sm:self-auto p-1.5 text-slate-400 hover:text-red-500 disabled:opacity-30"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
)}
</For>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:flex sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-4 border-t border-border">
<button
type="button"
onClick={() => setShowModal(false)}
class="w-full sm:w-auto rounded-md px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={saving() || !formName().trim()}
class="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving() ? 'Saving...' : editingRole() ? 'Update Role' : 'Create Role'}
</button>
</div>
</div>
</Dialog>
</Show>
<RolesEditorDialog
editingRole={state.editingRole()}
formDescription={state.formDescription()}
formId={state.formId()}
formName={state.formName()}
formPermissions={state.formPermissions()}
isOpen={state.showModal()}
saving={state.saving()}
onAddPermission={state.addPermission}
onClose={state.closeModal}
onFormDescriptionInput={state.setFormDescription}
onFormIdInput={state.setFormId}
onFormNameInput={state.setFormName}
onRemovePermission={state.removePermission}
onSave={state.handleSaveRole}
onUpdatePermission={state.updatePermission}
/>
</div>
);
};

View file

@ -0,0 +1,139 @@
import { Component, For, Show } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
import type { Permission, Role, UserRoleAssignment } from '@/types/rbac';
import BadgeCheck from 'lucide-solid/icons/badge-check';
import Shield from 'lucide-solid/icons/shield';
import X from 'lucide-solid/icons/x';
interface UserAssignmentsDialogProps {
editingUser: UserRoleAssignment | null;
formRoleIds: string[];
isOpen: boolean;
loadingPermissions: boolean;
roles: Role[];
saving: boolean;
userPermissions: Permission[];
onClose: () => void;
onSave: () => void;
onToggleRole: (roleId: string) => void;
}
export const UserAssignmentsDialog: Component<UserAssignmentsDialogProps> = (props) => (
<Show when={props.isOpen}>
<Dialog
isOpen={true}
onClose={props.onClose}
panelClass="max-w-2xl"
closeOnBackdrop={false}
ariaLabel={`Manage access: ${props.editingUser?.username ?? 'user'}`}
>
<div class="w-full max-h-[92vh] overflow-hidden">
<div class="flex items-start justify-between gap-3 px-4 sm:px-6 py-4 border-b border-border">
<div>
<h3 class="text-lg font-semibold text-base-content">
Manage Access: {props.editingUser?.username}
</h3>
<p class="text-xs text-muted uppercase tracking-wider font-semibold mt-0.5">
Role Assignments
</p>
</div>
<button
type="button"
onClick={props.onClose}
class="p-1.5 rounded-md hover:text-base-content hover:bg-surface-hover"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="px-4 sm:px-6 py-6 space-y-8 max-h-[70vh] overflow-y-auto">
<div class="space-y-4">
<h4 class="text-sm font-semibold text-base-content flex items-center gap-2">
<Shield class="w-4 h-4 text-blue-500" />
Select Roles
</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<For each={props.roles}>
{(role) => (
<label
class={`flex flex-col p-3 rounded-md border transition-all cursor-pointer ${props.formRoleIds.includes(role.id) ? 'bg-blue-50 border-blue-200 dark:bg-blue-900 dark:border-blue-800' : 'bg-surface border-border hover:border-blue-100 dark:hover:border-blue-900'}`}
>
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 shadow-sm">
<input
type="checkbox"
checked={props.formRoleIds.includes(role.id)}
onChange={() => props.onToggleRole(role.id)}
class="w-4 h-4 text-blue-600 rounded border-border focus:ring-blue-500"
/>
<span class="text-sm font-semibold text-base-content">{role.name}</span>
</div>
<Show when={role.isBuiltIn}>
<BadgeCheck class="w-4 h-4 text-blue-500" />
</Show>
</div>
<p class="text-xs text-muted line-clamp-2 leading-relaxed pl-6">
{role.description}
</p>
</label>
)}
</For>
</div>
</div>
<div class="space-y-4 pt-4 border-t border-border-subtle">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-base-content flex items-center gap-2">
<BadgeCheck class="w-4 h-4 text-blue-500" />
Effective Permissions Preview
</h4>
<Show when={props.loadingPermissions}>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500" />
</Show>
</div>
<div class="bg-surface-hover rounded-md p-4 border border-border-subtle">
<Show when={!props.loadingPermissions && props.userPermissions.length === 0}>
<p class="text-xs text-muted italic text-center py-2">
No effective permissions. This user will have no access.
</p>
</Show>
<div class="flex flex-wrap gap-2">
<For each={props.userPermissions}>
{(permission) => (
<span class="inline-flex items-center rounded-md bg-surface px-2.5 py-1 text-xs font-semibold text-base-content border border-border shadow-sm">
<span class="text-blue-600 dark:text-blue-400">{permission.action}</span>
<span class="mx-1 text-slate-400">:</span>
<span class="text-blue-600 dark:text-blue-400">{permission.resource}</span>
</span>
)}
</For>
</div>
<p class="mt-4 text-[10px] text-muted uppercase tracking-widest font-bold">
Note: Permissions are recalculated on save. This preview shows current server-side
state.
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:flex sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-5 border-t border-border bg-surface-alt rounded-b-xl">
<button
type="button"
onClick={props.onClose}
class="w-full sm:w-auto rounded-md px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={props.onSave}
disabled={props.saving}
class="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{props.saving ? 'Applying...' : 'Save Changes'}
</button>
</div>
</div>
</Dialog>
</Show>
);

View file

@ -1,202 +1,21 @@
import { Component, createSignal, createMemo, createEffect, onMount, Show, For } from 'solid-js';
import { Component, For, Show } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { RBACAPI } from '@/api/rbac';
import type { Role, UserRoleAssignment, Permission } from '@/types/rbac';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { Dialog } from '@/components/shared/Dialog';
import {
getUpgradeActionUrlOrFallback,
hasFeature,
loadLicenseStatus,
licenseLoaded,
startProTrial,
entitlements,
} from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { RBACFeatureGateSection } from './RBACFeatureGateSection';
import { UserAssignmentsDialog } from './UserAssignmentsDialog';
import { useUserAssignmentsPanelState } from './useUserAssignmentsPanelState';
import Users from 'lucide-solid/icons/users';
import Shield from 'lucide-solid/icons/shield';
import BadgeCheck from 'lucide-solid/icons/badge-check';
import X from 'lucide-solid/icons/x';
import Pencil from 'lucide-solid/icons/pencil';
import { SearchField } from '@/components/shared/SearchField';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import {
getRBACFeatureGateCopy,
getUserAssignmentsEmptyStateCopy,
getUserAssignmentsLoadErrorMessage,
getUserAssignmentsUpdateErrorMessage,
} from '@/utils/rbacPresentation';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
UPGRADE_TRIAL_LABEL,
UPGRADE_TRIAL_LINK_CLASS,
} from '@/utils/upgradePresentation';
export const UserAssignmentsPanel: Component = () => {
const [assignments, setAssignments] = createSignal<UserRoleAssignment[]>([]);
const [roles, setRoles] = createSignal<Role[]>([]);
const [loading, setLoading] = createSignal(true);
const [searchQuery, setSearchQuery] = createSignal('');
const [showModal, setShowModal] = createSignal(false);
const [editingUser, setEditingUser] = createSignal<UserRoleAssignment | null>(null);
const [saving, setSaving] = createSignal(false);
const [userPermissions, setUserPermissions] = createSignal<Permission[]>([]);
const [loadingPermissions, setLoadingPermissions] = createSignal(false);
const [startingTrial, setStartingTrial] = createSignal(false);
const featureGateCopy = () => getRBACFeatureGateCopy('user-assignments');
const state = useUserAssignmentsPanelState();
const emptyStateCopy = () => getUserAssignmentsEmptyStateCopy();
const canStartTrial = () => entitlements()?.trial_eligible !== false;
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined),
);
}
} finally {
setStartingTrial(false);
}
};
// Form state
const [formRoleIds, setFormRoleIds] = createSignal<string[]>([]);
const rbacEnabled = createMemo(() => licenseLoaded() && hasFeature('rbac'));
const loadData = async () => {
if (!rbacEnabled()) {
setAssignments([]);
setRoles([]);
setLoading(false);
return;
}
setLoading(true);
try {
const [usersData, rolesData] = await Promise.all([RBACAPI.getUsers(), RBACAPI.getRoles()]);
setAssignments(usersData || []);
setRoles(rolesData || []);
} catch (err) {
if (err instanceof Error && /feature not included in license/i.test(err.message)) {
setAssignments([]);
setRoles([]);
return;
}
logger.error('Failed to load user assignments', err);
notificationStore.error(getUserAssignmentsLoadErrorMessage());
} finally {
setLoading(false);
}
};
onMount(() => {
void loadLicenseStatus();
});
createEffect(() => {
if (!licenseLoaded()) {
setLoading(true);
return;
}
if (!hasFeature('rbac')) {
setAssignments([]);
setRoles([]);
setLoading(false);
return;
}
void loadData();
});
createEffect((wasPaywallVisible) => {
const isPaywallVisible = licenseLoaded() && !hasFeature('rbac') && !loading();
if (isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('rbac', 'settings_user_assignments_panel');
}
return isPaywallVisible;
}, false);
const filteredAssignments = createMemo(() => {
const query = searchQuery().toLowerCase();
if (!query) return assignments();
return assignments().filter((a) => a.username.toLowerCase().includes(query));
});
const handleEditRoles = async (assignment: UserRoleAssignment) => {
setEditingUser(assignment);
setFormRoleIds([...assignment.roleIds]);
setShowModal(true);
await loadUserPermissions(assignment.username);
};
const loadUserPermissions = async (username: string) => {
if (!rbacEnabled()) {
setUserPermissions([]);
return;
}
setLoadingPermissions(true);
try {
const perms = await RBACAPI.getUserPermissions(username);
setUserPermissions(perms || []);
} catch (err) {
if (err instanceof Error && /feature not included in license/i.test(err.message)) {
setUserPermissions([]);
return;
}
logger.error('Failed to load user permissions', err);
} finally {
setLoadingPermissions(false);
}
};
const handleSave = async () => {
const user = editingUser();
if (!user) return;
setSaving(true);
try {
await RBACAPI.updateUserRoles(user.username, formRoleIds());
notificationStore.success(`Roles updated for ${user.username}`);
setShowModal(false);
await loadData();
} catch (err) {
logger.error('Failed to update user roles', err);
notificationStore.error(getUserAssignmentsUpdateErrorMessage());
} finally {
setSaving(false);
}
};
const toggleRole = (roleId: string) => {
const current = formRoleIds();
if (current.includes(roleId)) {
setFormRoleIds(current.filter((id) => id !== roleId));
} else {
setFormRoleIds([...current, roleId]);
}
};
const getRoleName = (id: string) => {
return roles().find((r) => r.id === id)?.name || id;
};
return (
<div class="space-y-6">
<SettingsPanel
@ -206,9 +25,9 @@ export const UserAssignmentsPanel: Component = () => {
action={
<SearchField
placeholder="Search users..."
value={searchQuery()}
onChange={setSearchQuery}
disabled={!rbacEnabled()}
value={state.searchQuery()}
onChange={state.setSearchQuery}
disabled={!state.featureGate.rbacEnabled()}
class="min-w-[15rem]"
inputClass="min-h-10 sm:min-h-9 py-2.5"
/>
@ -216,47 +35,29 @@ export const UserAssignmentsPanel: Component = () => {
noPadding
bodyClass="divide-y divide-border"
>
<Show when={licenseLoaded() && !hasFeature('rbac') && !loading()}>
<div class="bg-surface-alt p-4 sm:p-6 transition-colors border-b border-border-subtle">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1 text-center sm:text-left">
<h4 class="text-base font-semibold text-base-content">{featureGateCopy().title}</h4>
<p class="text-sm text-muted mt-1">
{featureGateCopy().body}
</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<a
href={getUpgradeActionUrlOrFallback('rbac')}
target="_blank"
rel="noopener noreferrer"
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked('settings_user_assignments_panel', 'rbac')}
>
{UPGRADE_ACTION_LABEL}
</a>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</div>
</div>
<Show when={state.featureGate.paywallVisible()}>
<RBACFeatureGateSection
canStartTrial={state.featureGate.canStartTrial()}
copy={state.featureGate.featureGateCopy()}
paywallLocation="settings_user_assignments_panel"
startingTrial={state.featureGate.startingTrial()}
onStartTrial={state.featureGate.handleStartTrial}
/>
</Show>
<Show when={loading()}>
<Show when={state.loading()}>
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
</div>
</Show>
<Show when={!loading() && rbacEnabled() && filteredAssignments().length === 0}>
<Show
when={
!state.loading() &&
state.featureGate.rbacEnabled() &&
state.filteredAssignments().length === 0
}
>
<div class="text-center py-12 px-6">
<Users class="w-12 h-12 mx-auto text-slate-300 mb-4" />
<h4 class="text-base font-medium text-base-content mb-2">{emptyStateCopy().title}</h4>
@ -274,10 +75,16 @@ export const UserAssignmentsPanel: Component = () => {
</div>
</Show>
<Show when={!loading() && rbacEnabled() && filteredAssignments().length > 0}>
<Show
when={
!state.loading() &&
state.featureGate.rbacEnabled() &&
state.filteredAssignments().length > 0
}
>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={filteredAssignments()}
data={state.filteredAssignments()}
columns={[
{
key: 'username',
@ -298,7 +105,7 @@ export const UserAssignmentsPanel: Component = () => {
{(roleId) => (
<span class="inline-flex items-center gap-1 rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content border border-border">
<Shield class="w-3 h-3" />
{getRoleName(roleId)}
{state.getRoleName(roleId)}
</span>
)}
</For>
@ -312,7 +119,7 @@ export const UserAssignmentsPanel: Component = () => {
render: (assignment) => (
<button
type="button"
onClick={() => handleEditRoles(assignment)}
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" />
@ -330,126 +137,18 @@ export const UserAssignmentsPanel: Component = () => {
</Show>
</SettingsPanel>
{/* Assignments Modal */}
<Show when={showModal()}>
<Dialog
isOpen={true}
onClose={() => setShowModal(false)}
panelClass="max-w-2xl"
closeOnBackdrop={false}
ariaLabel={`Manage access: ${editingUser()?.username ?? 'user'}`}
>
<div class="w-full max-h-[92vh] overflow-hidden">
<div class="flex items-start justify-between gap-3 px-4 sm:px-6 py-4 border-b border-border">
<div>
<h3 class="text-lg font-semibold text-base-content">
Manage Access: {editingUser()?.username}
</h3>
<p class="text-xs text-muted uppercase tracking-wider font-semibold mt-0.5">
Role Assignments
</p>
</div>
<button
type="button"
onClick={() => setShowModal(false)}
class="p-1.5 rounded-md hover:text-base-content hover:bg-surface-hover"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="px-4 sm:px-6 py-6 space-y-8 max-h-[70vh] overflow-y-auto">
{/* Role Selection */}
<div class="space-y-4">
<h4 class="text-sm font-semibold text-base-content flex items-center gap-2">
<Shield class="w-4 h-4 text-blue-500" />
Select Roles
</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<For each={roles()}>
{(role) => (
<label
class={`flex flex-col p-3 rounded-md border transition-all cursor-pointer ${formRoleIds().includes(role.id) ? 'bg-blue-50 border-blue-200 dark:bg-blue-900 dark:border-blue-800' : 'bg-surface border-border hover:border-blue-100 dark:hover:border-blue-900'}`}
>
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 shadow-sm">
<input
type="checkbox"
checked={formRoleIds().includes(role.id)}
onChange={() => toggleRole(role.id)}
class="w-4 h-4 text-blue-600 rounded border-border focus:ring-blue-500"
/>
<span class="text-sm font-semibold text-base-content">{role.name}</span>
</div>
<Show when={role.isBuiltIn}>
<BadgeCheck class="w-4 h-4 text-blue-500" />
</Show>
</div>
<p class="text-xs text-muted line-clamp-2 leading-relaxed pl-6">
{role.description}
</p>
</label>
)}
</For>
</div>
</div>
{/* Effective Permissions Preview */}
<div class="space-y-4 pt-4 border-t border-border-subtle">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-base-content flex items-center gap-2">
<BadgeCheck class="w-4 h-4 text-blue-500" />
Effective Permissions Preview
</h4>
<Show when={loadingPermissions()}>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500" />
</Show>
</div>
<div class="bg-surface-hover rounded-md p-4 border border-border-subtle">
<Show when={!loadingPermissions() && userPermissions().length === 0}>
<p class="text-xs text-muted italic text-center py-2">
No effective permissions. This user will have no access.
</p>
</Show>
<div class="flex flex-wrap gap-2">
<For each={userPermissions()}>
{(perm) => (
<span class="inline-flex items-center rounded-md bg-surface px-2.5 py-1 text-xs font-semibold text-base-content border border-border shadow-sm">
<span class="text-blue-600 dark:text-blue-400">{perm.action}</span>
<span class="mx-1 text-slate-400">:</span>
<span class="text-blue-600 dark:text-blue-400">{perm.resource}</span>
</span>
)}
</For>
</div>
<p class="mt-4 text-[10px] text-muted uppercase tracking-widest font-bold">
Note: Permissions are recalculated on save. This preview shows current
server-side state.
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:flex sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-5 border-t border-border bg-surface-alt rounded-b-xl">
<button
type="button"
onClick={() => setShowModal(false)}
class="w-full sm:w-auto rounded-md px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={saving()}
class="inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-md bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving() ? 'Applying...' : 'Save Changes'}
</button>
</div>
</div>
</Dialog>
</Show>
<UserAssignmentsDialog
editingUser={state.editingUser()}
formRoleIds={state.formRoleIds()}
isOpen={state.showModal()}
loadingPermissions={state.loadingPermissions()}
roles={state.roles()}
saving={state.saving()}
userPermissions={state.userPermissions()}
onClose={state.closeModal}
onSave={state.handleSaveAssignments}
onToggleRole={state.toggleRole}
/>
</div>
);
};

View file

@ -136,8 +136,14 @@ import relayOnboardingCardSource from '@/components/Dashboard/RelayOnboardingCar
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
import networkSettingsPanelSource from '../NetworkSettingsPanel.tsx?raw';
import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw';
import rbacFeatureGateSectionSource from '../RBACFeatureGateSection.tsx?raw';
import rbacFeatureGateStateSource from '../useRBACFeatureGateState.ts?raw';
import rolesPanelSource from '../RolesPanel.tsx?raw';
import rolesEditorDialogSource from '../RolesEditorDialog.tsx?raw';
import rolesPanelStateSource from '../useRolesPanelState.ts?raw';
import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw';
import userAssignmentsDialogSource from '../UserAssignmentsDialog.tsx?raw';
import userAssignmentsPanelStateSource from '../useUserAssignmentsPanelState.ts?raw';
import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw';
import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw';
import ssoProvidersModelSource from '../ssoProvidersModel.ts?raw';
@ -529,8 +535,7 @@ describe('monitored-system model guardrails', () => {
expect(aiCostPresentationSource).toContain('export const AI_COST_EMPTY_STATE');
expect(aiCostPresentationSource).toContain('export const AI_COST_DAILY_USD_EMPTY_STATE');
expect(aiCostPresentationSource).toContain('export const AI_COST_DAILY_TOKEN_EMPTY_STATE');
expect(rolesPanelSource).toContain('@/utils/upgradePresentation');
expect(userAssignmentsPanelSource).toContain('@/utils/upgradePresentation');
expect(rbacFeatureGateSectionSource).toContain('@/utils/upgradePresentation');
expect(agentProfilesPanelStateSource).toContain('@/utils/upgradePresentation');
expect(auditLogPanelSource).toContain('@/utils/upgradePresentation');
expect(ssoProvidersPanelSource).toContain('@/utils/upgradePresentation');
@ -964,12 +969,11 @@ describe('monitored-system model guardrails', () => {
});
it('keeps RBAC permission vocabulary on shared utilities', () => {
expect(rolesPanelSource).toContain('@/utils/rbacPermissions');
expect(rolesPanelSource).toContain('@/utils/rbacPresentation');
expect(rolesPanelSource).toContain('RBAC_PERMISSION_ACTIONS');
expect(rolesPanelSource).toContain('RBAC_PERMISSION_RESOURCES');
expect(rolesPanelSource).toContain('createDefaultRBACPermission');
expect(rolesPanelSource).toContain('getRBACFeatureGateCopy');
expect(rolesPanelSource).toContain('./RBACFeatureGateSection');
expect(rolesPanelSource).toContain('./RolesEditorDialog');
expect(rolesPanelSource).toContain('./useRolesPanelState');
expect(rbacFeatureGateStateSource).toContain('getRBACFeatureGateCopy');
expect(rolesPanelSource).toContain('getRolesEmptyState');
expect(rolesPanelSource).not.toContain(
"const ACTIONS = ['read', 'write', 'delete', 'admin', '*']",
@ -982,6 +986,7 @@ describe('monitored-system model guardrails', () => {
expect(rbacPermissionsSource).toContain('export const RBAC_PERMISSION_ACTIONS');
expect(rbacPermissionsSource).toContain('export const RBAC_PERMISSION_RESOURCES');
expect(rbacPermissionsSource).toContain('export function createDefaultRBACPermission');
expect(rolesPanelStateSource).toContain('@/utils/rbacPermissions');
expect(rbacPresentationSource).toContain('export function getRBACFeatureGateCopy');
expect(rbacPresentationSource).toContain('export function getRolesEmptyState');
expect(rbacPresentationSource).toContain('export function getRolesLoadErrorMessage');
@ -989,12 +994,23 @@ describe('monitored-system model guardrails', () => {
expect(rbacPresentationSource).toContain('export function getUserAssignmentsLoadErrorMessage');
expect(rbacPresentationSource).toContain('export function getUserAssignmentsEmptyStateCopy');
expect(userAssignmentsPanelSource).toContain('@/utils/rbacPresentation');
expect(userAssignmentsPanelSource).toContain('getRBACFeatureGateCopy');
expect(userAssignmentsPanelSource).toContain('./RBACFeatureGateSection');
expect(userAssignmentsPanelSource).toContain('./UserAssignmentsDialog');
expect(userAssignmentsPanelSource).toContain('./useUserAssignmentsPanelState');
expect(rbacFeatureGateStateSource).toContain('getRBACFeatureGateCopy');
expect(userAssignmentsPanelSource).toContain('getUserAssignmentsEmptyStateCopy');
expect(userAssignmentsPanelSource).toContain('getUserAssignmentsLoadErrorMessage');
expect(userAssignmentsPanelSource).toContain('getUserAssignmentsUpdateErrorMessage');
expect(rolesPanelSource).toContain('getRolesLoadErrorMessage');
expect(rolesPanelSource).toContain('getRolesRequiredFieldsMessage');
expect(userAssignmentsPanelStateSource).toContain('getUserAssignmentsLoadErrorMessage');
expect(userAssignmentsPanelStateSource).toContain('getUserAssignmentsUpdateErrorMessage');
expect(rolesPanelStateSource).toContain('getRolesLoadErrorMessage');
expect(rolesPanelStateSource).toContain('getRolesRequiredFieldsMessage');
expect(rolesEditorDialogSource).toContain('RBAC_PERMISSION_ACTIONS');
expect(rolesEditorDialogSource).toContain('RBAC_PERMISSION_RESOURCES');
expect(rbacFeatureGateSectionSource).toContain('trackUpgradeClicked');
expect(rbacFeatureGateStateSource).toContain('trackPaywallViewed');
expect(rbacFeatureGateStateSource).toContain('startProTrial');
expect(userAssignmentsDialogSource).toContain('Effective Permissions Preview');
expect(userAssignmentsPanelStateSource).toContain('RBACAPI.getUsers');
expect(userAssignmentsPanelStateSource).toContain('RBACAPI.updateUserRoles');
expect(rolesPanelSource).not.toContain("notificationStore.error('Failed to load roles')");
expect(rolesPanelSource).not.toContain("notificationStore.error('Failed to save role')");
expect(userAssignmentsPanelSource).not.toContain(

View file

@ -66,8 +66,14 @@ import diagnosticsStateSource from '../useDiagnosticsPanelState.ts?raw';
import reportingPanelModelSource from '../reportingPanelModel.ts?raw';
import reportingPanelSource from '../ReportingPanel.tsx?raw';
import reportingPanelStateSource from '../useReportingPanelState.ts?raw';
import rbacFeatureGateSectionSource from '../RBACFeatureGateSection.tsx?raw';
import rbacFeatureGateStateSource from '../useRBACFeatureGateState.ts?raw';
import rolesPanelSource from '../RolesPanel.tsx?raw';
import rolesEditorDialogSource from '../RolesEditorDialog.tsx?raw';
import rolesPanelStateSource from '../useRolesPanelState.ts?raw';
import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw';
import userAssignmentsDialogSource from '../UserAssignmentsDialog.tsx?raw';
import userAssignmentsPanelStateSource from '../useUserAssignmentsPanelState.ts?raw';
import { SETTINGS_HEADER_META } from '../settingsHeaderMeta';
const extractedModules = [
@ -108,6 +114,12 @@ const extractedModules = [
'../UpdateInstallGuide.tsx',
'../ReportingPanel.tsx',
'../reportingPanelModel.ts',
'../RBACFeatureGateSection.tsx',
'../RolesEditorDialog.tsx',
'../useRBACFeatureGateState.ts',
'../useRolesPanelState.ts',
'../UserAssignmentsDialog.tsx',
'../useUserAssignmentsPanelState.ts',
'../updatesSettingsModel.ts',
'../useDiagnosticsPanelState.ts',
'../useReportingPanelState.ts',
@ -358,6 +370,28 @@ describe('Settings architecture guardrails', () => {
expect(proxmoxSettingsPanelStateSource).toContain("notificationStore.info('Refreshing discovery...'");
});
it('keeps RBAC settings panels split into gate, state, and dialog owners', () => {
expect(rolesPanelSource).toContain('./RBACFeatureGateSection');
expect(rolesPanelSource).toContain('./RolesEditorDialog');
expect(rolesPanelSource).toContain('./useRolesPanelState');
expect(rolesPanelSource).not.toContain('const handleStartTrial = async');
expect(rolesPanelSource).not.toContain('const loadRoles = async');
expect(userAssignmentsPanelSource).toContain('./RBACFeatureGateSection');
expect(userAssignmentsPanelSource).toContain('./UserAssignmentsDialog');
expect(userAssignmentsPanelSource).toContain('./useUserAssignmentsPanelState');
expect(userAssignmentsPanelSource).not.toContain('const handleStartTrial = async');
expect(userAssignmentsPanelSource).not.toContain('const loadData = async');
expect(rbacFeatureGateSectionSource).toContain('trackUpgradeClicked');
expect(rbacFeatureGateStateSource).toContain('trackPaywallViewed');
expect(rbacFeatureGateStateSource).toContain('startProTrial');
expect(rolesEditorDialogSource).toContain('RBAC_PERMISSION_ACTIONS');
expect(rolesPanelStateSource).toContain('RBACAPI.getRoles');
expect(rolesPanelStateSource).toContain('RBACAPI.saveRole');
expect(userAssignmentsDialogSource).toContain('Effective Permissions Preview');
expect(userAssignmentsPanelStateSource).toContain('RBACAPI.getUsers');
expect(userAssignmentsPanelStateSource).toContain('RBACAPI.updateUserRoles');
});
it('uses lazy() imports for panel components in settingsPanelRegistry', async () => {
const source = (await import('../settingsPanelRegistry.ts?raw')).default;

View file

@ -0,0 +1,87 @@
import { Accessor, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import {
entitlements,
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import { getRBACFeatureGateCopy, type RBACFeatureGateCopy } from '@/utils/rbacPresentation';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
export type RBACFeatureGateKind = 'roles' | 'user-assignments';
export type RBACFeatureGateLocation =
| 'settings_roles_panel'
| 'settings_user_assignments_panel';
interface UseRBACFeatureGateStateOptions {
kind: RBACFeatureGateKind;
loading: Accessor<boolean>;
paywallLocation: RBACFeatureGateLocation;
}
export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions) {
const [startingTrial, setStartingTrial] = createSignal(false);
const featureGateCopy = createMemo<RBACFeatureGateCopy>(() =>
getRBACFeatureGateCopy(options.kind),
);
const licenseReady = createMemo(() => licenseLoaded());
const canStartTrial = createMemo(() => entitlements()?.trial_eligible !== false);
const rbacEnabled = createMemo(() => licenseReady() && hasFeature('rbac'));
const paywallVisible = createMemo(
() => licenseReady() && !hasFeature('rbac') && !options.loading(),
);
onMount(() => {
void loadLicenseStatus();
});
createEffect((wasPaywallVisible) => {
const isPaywallVisible = paywallVisible();
if (isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('rbac', options.paywallLocation);
}
return isPaywallVisible;
}, false);
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined),
);
}
} finally {
setStartingTrial(false);
}
};
return {
canStartTrial,
featureGateCopy,
handleStartTrial,
licenseReady,
paywallVisible,
rbacEnabled,
startingTrial,
};
}

View file

@ -0,0 +1,173 @@
import { createEffect, createSignal } from 'solid-js';
import { RBACAPI } from '@/api/rbac';
import type { Permission, Role } from '@/types/rbac';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { createDefaultRBACPermission } from '@/utils/rbacPermissions';
import {
getRolesDeleteErrorMessage,
getRolesLoadErrorMessage,
getRolesRequiredFieldsMessage,
getRolesSaveErrorMessage,
} from '@/utils/rbacPresentation';
import { useRBACFeatureGateState } from './useRBACFeatureGateState';
export function useRolesPanelState() {
const [roles, setRoles] = createSignal<Role[]>([]);
const [loading, setLoading] = createSignal(true);
const [showModal, setShowModal] = createSignal(false);
const [editingRole, setEditingRole] = createSignal<Role | null>(null);
const [saving, setSaving] = createSignal(false);
const [formId, setFormId] = createSignal('');
const [formName, setFormName] = createSignal('');
const [formDescription, setFormDescription] = createSignal('');
const [formPermissions, setFormPermissions] = createSignal<Permission[]>([]);
const featureGate = useRBACFeatureGateState({
kind: 'roles',
loading,
paywallLocation: 'settings_roles_panel',
});
const loadRoles = async () => {
if (!featureGate.rbacEnabled()) {
setRoles([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await RBACAPI.getRoles();
setRoles(data || []);
} catch (err) {
if (err instanceof Error && /feature not included in license/i.test(err.message)) {
setRoles([]);
return;
}
logger.error('Failed to load roles', err);
notificationStore.error(getRolesLoadErrorMessage());
} finally {
setLoading(false);
}
};
createEffect(() => {
if (!featureGate.licenseReady()) {
setLoading(true);
return;
}
if (!featureGate.rbacEnabled()) {
setRoles([]);
setLoading(false);
return;
}
void loadRoles();
});
const closeModal = () => {
setShowModal(false);
};
const openCreateRole = () => {
setEditingRole(null);
setFormId('');
setFormName('');
setFormDescription('');
setFormPermissions([createDefaultRBACPermission()]);
setShowModal(true);
};
const openEditRole = (role: Role) => {
if (role.isBuiltIn) return;
setEditingRole(role);
setFormId(role.id);
setFormName(role.name);
setFormDescription(role.description);
setFormPermissions([...role.permissions]);
setShowModal(true);
};
const handleDeleteRole = async (role: Role) => {
if (role.isBuiltIn) return;
if (!confirm(`Are you sure you want to delete the role "${role.name}"?`)) return;
try {
await RBACAPI.deleteRole(role.id);
notificationStore.success(`Role "${role.name}" deleted`);
await loadRoles();
} catch (err) {
logger.error('Failed to delete role', err);
notificationStore.error(getRolesDeleteErrorMessage());
}
};
const handleSaveRole = async () => {
const id = formId().trim().toLowerCase().replace(/\s+/g, '-');
const name = formName().trim();
if (!id || !name) {
notificationStore.error(getRolesRequiredFieldsMessage());
return;
}
setSaving(true);
try {
const role: Role = {
id,
name,
description: formDescription(),
permissions: formPermissions(),
createdAt: editingRole()?.createdAt,
};
await RBACAPI.saveRole(role);
notificationStore.success(`Role "${name}" saved`);
closeModal();
await loadRoles();
} catch (err) {
logger.error('Failed to save role', err);
notificationStore.error(getRolesSaveErrorMessage());
} finally {
setSaving(false);
}
};
const addPermission = () => {
setFormPermissions([...formPermissions(), createDefaultRBACPermission()]);
};
const removePermission = (index: number) => {
const nextPermissions = [...formPermissions()];
nextPermissions.splice(index, 1);
setFormPermissions(nextPermissions);
};
const updatePermission = (index: number, field: keyof Permission, value: string) => {
const nextPermissions = [...formPermissions()];
nextPermissions[index] = { ...nextPermissions[index], [field]: value };
setFormPermissions(nextPermissions);
};
return {
addPermission,
closeModal,
editingRole,
featureGate,
formDescription,
formId,
formName,
formPermissions,
handleDeleteRole,
handleSaveRole,
loading,
openCreateRole,
openEditRole,
removePermission,
roles,
saving,
setFormDescription,
setFormId,
setFormName,
showModal,
updatePermission,
};
}

View file

@ -0,0 +1,156 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import { RBACAPI } from '@/api/rbac';
import type { Permission, Role, UserRoleAssignment } from '@/types/rbac';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
getUserAssignmentsLoadErrorMessage,
getUserAssignmentsUpdateErrorMessage,
} from '@/utils/rbacPresentation';
import { useRBACFeatureGateState } from './useRBACFeatureGateState';
export function useUserAssignmentsPanelState() {
const [assignments, setAssignments] = createSignal<UserRoleAssignment[]>([]);
const [roles, setRoles] = createSignal<Role[]>([]);
const [loading, setLoading] = createSignal(true);
const [searchQuery, setSearchQuery] = createSignal('');
const [showModal, setShowModal] = createSignal(false);
const [editingUser, setEditingUser] = createSignal<UserRoleAssignment | null>(null);
const [saving, setSaving] = createSignal(false);
const [userPermissions, setUserPermissions] = createSignal<Permission[]>([]);
const [loadingPermissions, setLoadingPermissions] = createSignal(false);
const [formRoleIds, setFormRoleIds] = createSignal<string[]>([]);
const featureGate = useRBACFeatureGateState({
kind: 'user-assignments',
loading,
paywallLocation: 'settings_user_assignments_panel',
});
const loadData = async () => {
if (!featureGate.rbacEnabled()) {
setAssignments([]);
setRoles([]);
setLoading(false);
return;
}
setLoading(true);
try {
const [usersData, rolesData] = await Promise.all([RBACAPI.getUsers(), RBACAPI.getRoles()]);
setAssignments(usersData || []);
setRoles(rolesData || []);
} catch (err) {
if (err instanceof Error && /feature not included in license/i.test(err.message)) {
setAssignments([]);
setRoles([]);
return;
}
logger.error('Failed to load user assignments', err);
notificationStore.error(getUserAssignmentsLoadErrorMessage());
} finally {
setLoading(false);
}
};
createEffect(() => {
if (!featureGate.licenseReady()) {
setLoading(true);
return;
}
if (!featureGate.rbacEnabled()) {
setAssignments([]);
setRoles([]);
setLoading(false);
return;
}
void loadData();
});
const filteredAssignments = createMemo(() => {
const query = searchQuery().toLowerCase();
if (!query) return assignments();
return assignments().filter((assignment) => assignment.username.toLowerCase().includes(query));
});
const loadUserPermissions = async (username: string) => {
if (!featureGate.rbacEnabled()) {
setUserPermissions([]);
return;
}
setLoadingPermissions(true);
try {
const permissions = await RBACAPI.getUserPermissions(username);
setUserPermissions(permissions || []);
} catch (err) {
if (err instanceof Error && /feature not included in license/i.test(err.message)) {
setUserPermissions([]);
return;
}
logger.error('Failed to load user permissions', err);
} finally {
setLoadingPermissions(false);
}
};
const closeModal = () => {
setShowModal(false);
};
const openManageAccess = async (assignment: UserRoleAssignment) => {
setEditingUser(assignment);
setFormRoleIds([...assignment.roleIds]);
setShowModal(true);
await loadUserPermissions(assignment.username);
};
const handleSaveAssignments = async () => {
const user = editingUser();
if (!user) return;
setSaving(true);
try {
await RBACAPI.updateUserRoles(user.username, formRoleIds());
notificationStore.success(`Roles updated for ${user.username}`);
closeModal();
await loadData();
} catch (err) {
logger.error('Failed to update user roles', err);
notificationStore.error(getUserAssignmentsUpdateErrorMessage());
} finally {
setSaving(false);
}
};
const toggleRole = (roleId: string) => {
const currentRoleIds = formRoleIds();
if (currentRoleIds.includes(roleId)) {
setFormRoleIds(currentRoleIds.filter((id) => id !== roleId));
return;
}
setFormRoleIds([...currentRoleIds, roleId]);
};
const getRoleName = (roleId: string) =>
roles().find((role) => role.id === roleId)?.name || roleId;
return {
closeModal,
editingUser,
featureGate,
filteredAssignments,
formRoleIds,
getRoleName,
handleSaveAssignments,
loading,
loadingPermissions,
openManageAccess,
roles,
saving,
searchQuery,
setSearchQuery,
showModal,
toggleRole,
userPermissions,
};
}

View file

@ -288,6 +288,8 @@ import organizationOverviewPanelSource from '@/components/Settings/OrganizationO
import organizationSharingPanelSource from '@/components/Settings/OrganizationSharingPanel.tsx?raw';
import organizationRolePresentationSource from '@/utils/organizationRolePresentation.ts?raw';
import organizationSettingsPresentationSource from '@/utils/organizationSettingsPresentation.ts?raw';
import rbacFeatureGateSectionSource from '@/components/Settings/RBACFeatureGateSection.tsx?raw';
import rbacFeatureGateStateSource from '@/components/Settings/useRBACFeatureGateState.ts?raw';
const recoverySource = [
recoveryComponentSource,
@ -298,6 +300,7 @@ const recoverySource = [
recoveryHistorySectionStateSource,
].join('\n');
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
import rolesPanelStateSource from '@/components/Settings/useRolesPanelState.ts?raw';
import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw';
import auditWebhookStateSource from '@/components/Settings/useAuditWebhookPanelState.ts?raw';
import auditWebhookPresentationSource from '@/utils/auditWebhookPresentation.ts?raw';
@ -309,6 +312,7 @@ import ssoProvidersStateSource from '@/components/Settings/useSSOProvidersState.
import ssoProvidersModelSource from '@/components/Settings/ssoProvidersModel.ts?raw';
import ssoProviderPresentationSource from '@/utils/ssoProviderPresentation.ts?raw';
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
import userAssignmentsPanelStateSource from '@/components/Settings/useUserAssignmentsPanelState.ts?raw';
import investigationMessagesSource from '@/components/patrol/InvestigationMessages.tsx?raw';
import investigationSectionSource from '@/components/patrol/InvestigationSection.tsx?raw';
import runHistoryPanelSource from '@/components/patrol/RunHistoryPanel.tsx?raw';
@ -803,8 +807,14 @@ describe('frontend resource type boundaries', () => {
expect(reportingPanelSource).not.toContain('>Upgrade to Pro<');
expect(reportingPanelSource).not.toContain('>Start free trial<');
expect(reportingPanelSource).not.toContain('window.URL.createObjectURL');
expect(rolesPanelSource).toContain('@/utils/upgradePresentation');
expect(userAssignmentsPanelSource).toContain('@/utils/upgradePresentation');
expect(rolesPanelSource).toContain('./RBACFeatureGateSection');
expect(rolesPanelSource).toContain('./useRolesPanelState');
expect(userAssignmentsPanelSource).toContain('./RBACFeatureGateSection');
expect(userAssignmentsPanelSource).toContain('./useUserAssignmentsPanelState');
expect(rbacFeatureGateSectionSource).toContain('@/utils/upgradePresentation');
expect(rbacFeatureGateSectionSource).toContain('getUpgradeActionButtonClass');
expect(rolesPanelStateSource).toContain('getRolesLoadErrorMessage');
expect(userAssignmentsPanelStateSource).toContain('getUserAssignmentsLoadErrorMessage');
expect(agentProfilesPanelSource).toContain('./useAgentProfilesPanelState');
expect(agentProfilesPanelStateSource).toContain('@/utils/upgradePresentation');
expect(agentProfilesPanelSource).toContain('@/utils/agentProfilesPresentation');
@ -921,17 +931,17 @@ describe('frontend resource type boundaries', () => {
expect(rbacPermissionsSource).toContain('export const RBAC_PERMISSION_RESOURCES');
expect(rbacPermissionsSource).toContain('export function createDefaultRBACPermission');
expect(rolesPanelSource).toContain('@/utils/rbacPresentation');
expect(rolesPanelSource).toContain('getRBACFeatureGateCopy');
expect(rolesPanelSource).toContain('getRolesEmptyState');
expect(rolesPanelSource).not.toContain('Custom Roles (Pro)');
expect(rolesPanelSource).not.toContain('No roles available.');
expect(rbacFeatureGateStateSource).toContain('getRBACFeatureGateCopy');
expect(userAssignmentsPanelSource).toContain('@/utils/rbacPresentation');
expect(userAssignmentsPanelSource).toContain('getRBACFeatureGateCopy');
expect(userAssignmentsPanelSource).toContain('getUserAssignmentsEmptyStateCopy');
expect(userAssignmentsPanelSource).not.toContain('Centralized Access Control (Pro)');
expect(userAssignmentsPanelSource).not.toContain('No users yet');
expect(userAssignmentsPanelSource).not.toContain('Configure SSO in Security settings');
expect(userAssignmentsPanelSource).not.toContain('Users sync on first login');
expect(rbacFeatureGateStateSource).toContain('getRBACFeatureGateCopy');
expect(rbacPresentationSource).toContain('export function getRBACFeatureGateCopy');
expect(rbacPresentationSource).toContain('export function getRolesEmptyState');
expect(rbacPresentationSource).toContain('export function getUserAssignmentsEmptyStateCopy');

View file

@ -1913,6 +1913,54 @@ class SubsystemLookupTest(unittest.TestCase):
],
)
def test_lookup_paths_assigns_roles_panel_state_to_organization_settings(self) -> None:
result = lookup_paths(["frontend-modern/src/components/Settings/useRolesPanelState.ts"])
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]},
{"organization-settings"},
)
file_entry = result["files"][0]
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(
{match["subsystem"] for match in file_entry["matches"]},
{"organization-settings"},
)
match = file_entry["matches"][0]
self.assertEqual(
match["contract"],
"docs/release-control/v6/internal/subsystems/organization-settings.md",
)
self.assertEqual(match["lane_context"]["lane_id"], "L14")
self.assertEqual(
match["verification_requirement"]["id"],
"organization-settings-surface",
)
def test_lookup_paths_assigns_user_assignments_dialog_to_organization_settings(self) -> None:
result = lookup_paths(["frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx"])
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]},
{"organization-settings"},
)
file_entry = result["files"][0]
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(
{match["subsystem"] for match in file_entry["matches"]},
{"organization-settings"},
)
match = file_entry["matches"][0]
self.assertEqual(
match["contract"],
"docs/release-control/v6/internal/subsystems/organization-settings.md",
)
self.assertEqual(match["lane_context"]["lane_id"], "L14")
self.assertEqual(
match["verification_requirement"]["id"],
"organization-settings-surface",
)
def test_lookup_paths_assigns_organization_model_to_organization_settings(self) -> None:
result = lookup_paths(["internal/models/organization.go"])
self.assertEqual(result["unowned_runtime_files"], [])