mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Split RBAC settings runtime owners
This commit is contained in:
parent
c6f627dc2f
commit
b486ac05cd
14 changed files with 1004 additions and 743 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
159
frontend-modern/src/components/Settings/RolesEditorDialog.tsx
Normal file
159
frontend-modern/src/components/Settings/RolesEditorDialog.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
173
frontend-modern/src/components/Settings/useRolesPanelState.ts
Normal file
173
frontend-modern/src/components/Settings/useRolesPanelState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"], [])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue