diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index c889a053f2..01484046ee 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index a5f235605b..32210393d7 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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" diff --git a/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx b/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx new file mode 100644 index 0000000000..d04236c0e5 --- /dev/null +++ b/frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx @@ -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; +} + +export const RBACFeatureGateSection: Component = (props) => ( +
+
+
+

{props.copy.title}

+

{props.copy.body}

+
+ +
+
+); diff --git a/frontend-modern/src/components/Settings/RolesEditorDialog.tsx b/frontend-modern/src/components/Settings/RolesEditorDialog.tsx new file mode 100644 index 0000000000..39344d3f08 --- /dev/null +++ b/frontend-modern/src/components/Settings/RolesEditorDialog.tsx @@ -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 = (props) => ( + + +
+
+

+ {props.editingRole ? 'Edit Role' : 'New Role'} +

+ +
+ +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+ +
+
+ + +
+ +
+ + {(permission, index) => ( +
+ + + + +
+ )} +
+
+
+
+ +
+ + +
+
+
+
+); diff --git a/frontend-modern/src/components/Settings/RolesPanel.tsx b/frontend-modern/src/components/Settings/RolesPanel.tsx index 9f98156450..7f2fa57b16 100644 --- a/frontend-modern/src/components/Settings/RolesPanel.tsx +++ b/frontend-modern/src/components/Settings/RolesPanel.tsx @@ -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([]); - const [loading, setLoading] = createSignal(true); - const [showModal, setShowModal] = createSignal(false); - const [editingRole, setEditingRole] = createSignal(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([]); - 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 (
@@ -221,8 +23,8 @@ export const RolesPanel: Component = () => { action={ - -
- - + + - +
- +
{ -
- -
-
-
- - 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" - /> -
-
- - 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" - /> -
-
-
- - 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" - /> -
- -
-
- - -
- -
- - {(perm, index) => ( -
- - - - -
- )} -
-
-
-
- -
- - -
-
- -
+ ); }; diff --git a/frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx b/frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx new file mode 100644 index 0000000000..6dd8640e38 --- /dev/null +++ b/frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx @@ -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 = (props) => ( + + +
+
+
+

+ Manage Access: {props.editingUser?.username} +

+

+ Role Assignments +

+
+ +
+ +
+
+

+ + Select Roles +

+
+ + {(role) => ( + + )} + +
+
+ +
+
+

+ + Effective Permissions Preview +

+ +
+ +
+
+ +

+ No effective permissions. This user will have no access. +

+
+
+ + {(permission) => ( + + {permission.action} + : + {permission.resource} + + )} + +
+

+ Note: Permissions are recalculated on save. This preview shows current server-side + state. +

+
+
+
+ +
+ + +
+
+
+
+); diff --git a/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx b/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx index 72d4180e9b..cb2850c654 100644 --- a/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx +++ b/frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx @@ -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([]); - const [roles, setRoles] = createSignal([]); - const [loading, setLoading] = createSignal(true); - const [searchQuery, setSearchQuery] = createSignal(''); - const [showModal, setShowModal] = createSignal(false); - const [editingUser, setEditingUser] = createSignal(null); - const [saving, setSaving] = createSignal(false); - const [userPermissions, setUserPermissions] = createSignal([]); - 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([]); - 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 (
{ action={ @@ -216,47 +35,29 @@ export const UserAssignmentsPanel: Component = () => { noPadding bodyClass="divide-y divide-border" > - -
-
-
-

{featureGateCopy().title}

-

- {featureGateCopy().body} -

-
- -
-
+ + - +
- +

{emptyStateCopy().title}

@@ -274,10 +75,16 @@ export const UserAssignmentsPanel: Component = () => {
- 0}> + 0 + } + >
{ {(roleId) => ( - {getRoleName(roleId)} + {state.getRoleName(roleId)} )} @@ -312,7 +119,7 @@ export const UserAssignmentsPanel: Component = () => { render: (assignment) => ( -
- -
- {/* Role Selection */} -
-

- - Select Roles -

-
- - {(role) => ( - - )} - -
-
- - {/* Effective Permissions Preview */} -
-
-

- - Effective Permissions Preview -

- -
- -
-
- -

- No effective permissions. This user will have no access. -

-
-
- - {(perm) => ( - - {perm.action} - : - {perm.resource} - - )} - -
-

- Note: Permissions are recalculated on save. This preview shows current - server-side state. -

-
-
-
- -
- - -
-
- -
+
); }; diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index 9c6a1cfc36..b2120b64ea 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -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( diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 59408d4e93..8e73dd3d1d 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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; diff --git a/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts b/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts new file mode 100644 index 0000000000..ed8dd4c1a8 --- /dev/null +++ b/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts @@ -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; + paywallLocation: RBACFeatureGateLocation; +} + +export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions) { + const [startingTrial, setStartingTrial] = createSignal(false); + + const featureGateCopy = createMemo(() => + 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, + }; +} diff --git a/frontend-modern/src/components/Settings/useRolesPanelState.ts b/frontend-modern/src/components/Settings/useRolesPanelState.ts new file mode 100644 index 0000000000..964c2ac98d --- /dev/null +++ b/frontend-modern/src/components/Settings/useRolesPanelState.ts @@ -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([]); + const [loading, setLoading] = createSignal(true); + const [showModal, setShowModal] = createSignal(false); + const [editingRole, setEditingRole] = createSignal(null); + const [saving, setSaving] = createSignal(false); + const [formId, setFormId] = createSignal(''); + const [formName, setFormName] = createSignal(''); + const [formDescription, setFormDescription] = createSignal(''); + const [formPermissions, setFormPermissions] = createSignal([]); + + 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, + }; +} diff --git a/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts b/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts new file mode 100644 index 0000000000..a2ef5d2872 --- /dev/null +++ b/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts @@ -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([]); + const [roles, setRoles] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [searchQuery, setSearchQuery] = createSignal(''); + const [showModal, setShowModal] = createSignal(false); + const [editingUser, setEditingUser] = createSignal(null); + const [saving, setSaving] = createSignal(false); + const [userPermissions, setUserPermissions] = createSignal([]); + const [loadingPermissions, setLoadingPermissions] = createSignal(false); + const [formRoleIds, setFormRoleIds] = createSignal([]); + + 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, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 5d41e3d323..1a1f9ff700 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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'); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index e09d556f11..7c9a7499d8 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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"], [])