diff --git a/cypress/e2e/1_acess_management.cy.js b/cypress/e2e/1_acess_management.cy.js index b4554236d..31357a383 100644 --- a/cypress/e2e/1_acess_management.cy.js +++ b/cypress/e2e/1_acess_management.cy.js @@ -22,19 +22,44 @@ describe('Global stats', () => { }); - it('successfully creates and removes a role', () => { + it('successfully creates, updates and removes a role', () => { // create cy.get('button[data-cy="createRoleButton"]').click(); cy.get("[aria-label=role-name-input]").type("aRole"); cy.get("[aria-label=role-description-input]").type("aRole description"); - cy.get("[data-cy=menu-toogle-permissions").click(); - cy.get("#option-typeahead-ALL").click(); - cy.get("[data-cy=menu-toogle-permissions").click(); + cy.get("[data-cy=menu-toogle-permissions]").click(); + cy.get("[data-cy=option-typeahead-ALL]").click(); + cy.get("[data-cy=menu-toogle-permissions]").click(); cy.get("[aria-label=Create]").click(); cy.contains('Role aRole has been created'); cy.contains('aRole description'); + cy.login(Cypress.env('username'), Cypress.env('password'), '/access-management/role/aRole'); + cy.get('[aria-label=role-name-input') + .should('have.value', 'aRole') + .should('be.disabled'); + cy.get('[aria-label=role-description-input]') + .should('have.value', 'aRole description') + .type(' with update'); + cy.get('[aria-label=Save').click(); + cy.login(Cypress.env('username'), Cypress.env('password'), '/access-management/role/aRole'); + cy.get('[aria-label=role-description-input]') + .should('have.value', 'aRole description with update'); + + cy.get('[aria-label=nav-item-Permissions').click(); + cy.contains('ALL'); + cy.get('[data-cy=addPermissionButton').click(); + cy.get('[data-cy=menu-toogle-permissions]').click(); + cy.get("[data-cy=option-typeahead-READ]").click(); + cy.get('[data-cy=menu-toogle-permissions]').click(); + cy.get('[aria-label=Save').click(); + cy.get("[aria-label=READ-menu]").click(); + cy.get("[aria-label=removePermission-READ]").click(); + cy.get("[aria-label=Remove]").click(); + cy.contains('Role aRole has been updated'); + // remove + cy.login(Cypress.env('username'), Cypress.env('password'), '/access-management'); cy.get("[aria-label=aRole-menu]").click(); cy.get("[aria-label=deleteRole]").click(); cy.get("[aria-label=Delete]").click(); diff --git a/src/app/AccessManagement/CreateRole.tsx b/src/app/AccessManagement/CreateRole.tsx index cea65390f..3578b3dda 100644 --- a/src/app/AccessManagement/CreateRole.tsx +++ b/src/app/AccessManagement/CreateRole.tsx @@ -16,27 +16,27 @@ import { useTranslation } from 'react-i18next'; import formUtils, { IField } from '@services/formUtils'; import { AddCircleOIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; import { useCreateRole, useFetchAvailableRoles } from '@app/services/rolesHook'; +import { PERMISSIONS_MAP } from '@services/infinispanRefData'; import { SelectMultiWithChips } from '@app/Common/SelectMultiWithChips'; const CreateRole = (props: { isModalOpen: boolean; submitModal: () => void; closeModal: () => void }) => { const { t } = useTranslation(); const { roles } = useFetchAvailableRoles(); - const initialSelectOptions: SelectOptionProps[] = [ - { value: 'ALL', children: 'ALL', description: t('access-management.roles.permission-all') }, - { value: 'ADMIN', children: 'ADMIN', description: t('access-management.roles.permission-admin') }, - { value: 'ALL_READ', children: 'ALL_READ', description: t('access-management.roles.permission-all-read') }, - { value: 'READ', children: 'READ', description: t('access-management.roles.permission-read') }, - { value: 'BULK_READ', children: 'BULK_READ', description: t('access-management.roles.permission-bulk-read') }, - { value: 'ALL_WRITE', children: 'ALL_WRITE', description: t('access-management.roles.permission-all-write') }, - { value: 'WRITE', children: 'WRITE', description: t('access-management.roles.permission-write') }, - { value: 'BULK_WRITE', children: 'BULK_WRITE', description: t('access-management.roles.permission-bulk-write') }, - { value: 'MONITOR', children: 'MONITOR', description: t('access-management.roles.permission-monitor') }, - { value: 'CREATE', children: 'CREATE', description: t('access-management.roles.permission-create') }, - { value: 'EXEC', children: 'EXEC', description: t('access-management.roles.permission-exec') }, - { value: 'LISTEN', children: 'LISTEN', description: t('access-management.roles.permission-listen') }, - { value: 'LIFECYCLE', children: 'LIFECYCLE', description: t('access-management.roles.permission-lifecycle') }, - { value: 'NONE', children: 'NONE', description: t('access-management.roles.permission-none') } - ]; + const initPermissions = () => { + const array: SelectOptionProps[] = []; + PERMISSIONS_MAP.forEach((value, key, map) => { + const desc = t(value); + array.push({ + id: key, + value: key, + children: key, + description: desc + }); + }); + return array; + }; + const initialSelectOptions: SelectOptionProps[] = initPermissions(); + const roleNameInitialState: IField = { value: '', isValid: false, @@ -131,10 +131,10 @@ const CreateRole = (props: { isModalOpen: boolean; submitModal: () => void; clos disableFocusTrap={true} actions={[ , ]} > diff --git a/src/app/AccessManagement/DeleteRole.tsx b/src/app/AccessManagement/DeleteRole.tsx index f23d065e6..d4ccea888 100644 --- a/src/app/AccessManagement/DeleteRole.tsx +++ b/src/app/AccessManagement/DeleteRole.tsx @@ -28,7 +28,7 @@ const DeleteRole = (props: { name: string; isModalOpen: boolean; submitModal: () onDeleteRole(); }} > - {t('access-management.roles.delete-action')} + {t('common.actions.delete')} , ]} > diff --git a/src/app/AccessManagement/RoleDetail.tsx b/src/app/AccessManagement/RoleDetail.tsx new file mode 100644 index 000000000..0fa4ed665 --- /dev/null +++ b/src/app/AccessManagement/RoleDetail.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from 'react'; +import { + Card, + CardBody, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleElement, + Nav, + NavItem, + NavList, + PageSection, + PageSectionVariants, + Text, + TextContent, + TextVariants, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { DataContainerBreadcrumb } from '@app/Common/DataContainerBreadcrumb'; +import { RoleGeneral } from '@app/AccessManagement/RoleDetailContent/RoleGeneral'; +import { RolePermissions } from '@app/AccessManagement/RoleDetailContent/RolePermissions'; +import { DeleteRole } from '@app/AccessManagement/DeleteRole'; +import { useHistory } from 'react-router'; +import { useDescribeRole } from '@app/services/rolesHook'; + +const RoleDetail = (props) => { + const roleName = decodeURIComponent(props.computedMatch.params.roleName); + const history = useHistory(); + const { t } = useTranslation(); + const { role } = useDescribeRole(roleName); + const [activeTabKey, setActiveTabKey] = useState('0'); + const [showGeneralDescription, setShowGeneralDescription] = useState(true); + const [showPermissions, setShowPermissions] = useState(false); + // const [showCaches, setShowCaches] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isDeleteRole, setIsDeleteRole] = useState(false); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + setIsOpen(false); + }; + + useEffect(() => { + setShowGeneralDescription(activeTabKey === '0'); + setShowPermissions(activeTabKey === '1'); + // setShowCaches(activeTabKey === '2'); + }, [activeTabKey]); + + interface AccessTab { + key: string; + name: string; + } + + const handleTabClick = (event, nav) => { + setActiveTabKey(nav.itemId); + }; + + const tabs: AccessTab[] = [ + { name: t('access-management.role.tab-general'), key: '0' }, + { name: t('access-management.role.tab-permissions'), key: '1' } + // { name: t('access-management.role.tab-caches'), key: '2' } + ]; + + const buildTabs = () => { + return ( + + ); + }; + + const displayActions = () => { + if (!role || role.implicit) { + return; + } + + return ( + + + setIsOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + + {t('access-management.role.actions')} + + )} + ouiaId="roleDetailDropdown" + shouldFocusToggleOnSelect + > + + setIsDeleteRole(true)}> + {t('common.actions.delete')} + + + + + + ); + }; + + return ( + <> + + + + + + + + {roleName} + + + + {displayActions()} + + + {buildTabs()} + + + + + {showGeneralDescription && } + {showPermissions && } + {/*{showCaches && }*/} + + + + { + setIsDeleteRole(false); + history.push('/access-management'); + }} + closeModal={() => { + setIsDeleteRole(false); + }} + /> + + ); +}; + +export { RoleDetail }; diff --git a/src/app/AccessManagement/RoleDetailContent/AddPermissions.tsx b/src/app/AccessManagement/RoleDetailContent/AddPermissions.tsx new file mode 100644 index 000000000..aa20009e9 --- /dev/null +++ b/src/app/AccessManagement/RoleDetailContent/AddPermissions.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { + Button, + ButtonVariant, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Modal, + ModalVariant, + SelectOptionProps +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { IField } from '@services/formUtils'; +import { AddCircleOIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; +import { useUpdateRole } from '@app/services/rolesHook'; +import { PERMISSIONS_MAP } from '@services/infinispanRefData'; +import { SelectMultiWithChips } from '@app/Common/SelectMultiWithChips'; + +const AddPermissions = (props: { + name: string; + permissions: string[]; + isModalOpen: boolean; + submitModal: () => void; + closeModal: () => void; +}) => { + const { t } = useTranslation(); + const initPermissions = () => { + const array: SelectOptionProps[] = []; + PERMISSIONS_MAP.forEach((value, key, map) => { + if (!props.permissions.includes(key)) { + const desc = t(value); + array.push({ + id: key, + value: key, + children: key, + description: desc + }); + } + }); + return array; + }; + const initialSelectOptions: SelectOptionProps[] = initPermissions(); + + const rolePermissionsInitialState: IField = { + value: '', + isValid: false, + validated: 'default' + }; + + const [rolePermissionsField, setRolePermissionsField] = useState(rolePermissionsInitialState); + const [selectedPermissions, setSelectedPermissions] = useState(props.permissions); + const textInputRef = React.useRef(); + const { onUpdateRole } = useUpdateRole(props.name, '', selectedPermissions, props.submitModal); + + const handleSubmit = () => { + if (selectedPermissions.length == 0) { + setRolePermissionsField({ + ...rolePermissionsField, + isValid: true, + invalidText: t('access-management.roles.modal-permissions-is-required'), + validated: 'error' + }); + } else { + onUpdateRole(); + } + }; + + const onCloseModal = () => { + props.closeModal(); + setSelectedPermissions([]); + }; + + const onSelectPermission = (value: string) => { + if (value && !props.permissions.includes(value)) { + setSelectedPermissions( + selectedPermissions.includes(value) + ? selectedPermissions.filter((selection) => selection !== value) + : [...selectedPermissions, value] + ); + } + textInputRef.current?.focus(); + }; + + return ( + + {t('common.actions.save')} + , + + ]} + > +
{ + e.preventDefault(); + }} + > + + setSelectedPermissions([])} + readonly={props.permissions} + /> + {rolePermissionsField.validated === 'error' && ( + + + }> + {rolePermissionsField.invalidText} + + + + )} + +
+
+ ); +}; + +export { AddPermissions }; diff --git a/src/app/AccessManagement/RoleDetailContent/RemovePermission.tsx b/src/app/AccessManagement/RoleDetailContent/RemovePermission.tsx new file mode 100644 index 000000000..5f8a63722 --- /dev/null +++ b/src/app/AccessManagement/RoleDetailContent/RemovePermission.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Button, ButtonVariant, Modal, Text, TextContent } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { useRemovePermission } from '@app/services/rolesHook'; + +const RemovePermission = (props: { + name: string; + remove: string; + permissions: string[]; + isModalOpen: boolean; + submitModal: () => void; + closeModal: () => void; +}) => { + const { t } = useTranslation(); + const { onRemovePermission } = useRemovePermission(props.name, props.remove, props.permissions, props.submitModal); + + return ( + { + onRemovePermission(); + }} + > + {t('common.actions.remove')} + , + + ]} + > + + {t('access-management.role.modal-remove-permission-description-1', { roleName: props.name })} + {t('access-management.role.modal-remove-permission-description-2')} + + + ); +}; + +export { RemovePermission }; diff --git a/src/app/AccessManagement/RoleDetailContent/RoleCaches.tsx b/src/app/AccessManagement/RoleDetailContent/RoleCaches.tsx new file mode 100644 index 000000000..96ac352c2 --- /dev/null +++ b/src/app/AccessManagement/RoleDetailContent/RoleCaches.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from 'react-i18next'; + +const RoleCaches = () => { + const { t } = useTranslation(); + const brandname = t('brandname.brandname'); + + return 'Caches'; +}; + +export { RoleCaches }; diff --git a/src/app/AccessManagement/RoleDetailContent/RoleGeneral.tsx b/src/app/AccessManagement/RoleDetailContent/RoleGeneral.tsx new file mode 100644 index 000000000..abca7acec --- /dev/null +++ b/src/app/AccessManagement/RoleDetailContent/RoleGeneral.tsx @@ -0,0 +1,158 @@ +import { useTranslation } from 'react-i18next'; +import { useDescribeRole, useUpdateRole } from '@app/services/rolesHook'; +import { + ActionGroup, + Alert, + Button, + ButtonVariant, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + Form, + FormGroup, + Spinner, + TextInput +} from '@patternfly/react-core'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { global_danger_color_200 } from '@patternfly/react-tokens'; +import { Link } from 'react-router-dom'; +import { IField } from '@services/formUtils'; + +const RoleGeneral = (props: { name: string }) => { + const roleNameInitialState: IField = { + value: '', + isValid: false, + validated: 'default' + }; + + const roleDescriptionInitialState: IField = { + value: '', + isValid: false, + validated: 'default' + }; + + const { t } = useTranslation(); + const { role, loading, error, setLoading } = useDescribeRole(props.name); + const [roleName, setRoleName] = useState(roleNameInitialState); + const [roleDescription, setRoleDescription] = useState(roleDescriptionInitialState); + const [isImplicit, setIsImplicit] = useState(false); + const { onUpdateRole } = useUpdateRole(props.name, roleDescription.value, [], () => setLoading(true)); + + useEffect(() => { + if (role) { + setRoleName({ + ...roleName, + value: role.name + }); + setRoleDescription({ + ...roleDescription, + value: role.description + }); + setIsImplicit(role.implicit); + } + }, [role]); + + const buildDetailContent = () => { + if (loading && !role) { + return ; + } + + if (error !== '') { + return ( + + } + headingLevel="h2" + /> + {error} + + + + + + + + + ); + } + + const displayImplicitRoleMessage = () => { + return ( + role?.implicit && ( + + ) + ); + }; + return ( +
{ + e.preventDefault(); + }} + > + {displayImplicitRoleMessage()} + + + + + + setRoleDescription({ + ...roleDescription, + value: value + }) + } + aria-label="role-description-input" + /> + + + + + +
+ ); + }; + + return buildDetailContent(); +}; + +export { RoleGeneral }; diff --git a/src/app/AccessManagement/RoleDetailContent/RolePermissions.tsx b/src/app/AccessManagement/RoleDetailContent/RolePermissions.tsx new file mode 100644 index 000000000..d70d80b74 --- /dev/null +++ b/src/app/AccessManagement/RoleDetailContent/RolePermissions.tsx @@ -0,0 +1,250 @@ +import { useTranslation } from 'react-i18next'; +import { + Alert, + Bullseye, + Button, + ButtonVariant, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Pagination, + SearchInput, + Title, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarItemVariant +} from '@patternfly/react-core'; +import React, { useEffect, useState } from 'react'; +import { ActionsColumn, IAction, Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { useDescribeRole } from '@app/services/rolesHook'; +import { PERMISSIONS_MAP } from '@services/infinispanRefData'; +import { TableErrorState } from '@app/Common/TableErrorState'; +import { TableLoadingState } from '@app/Common/TableLoadingState'; +import { SearchIcon } from '@patternfly/react-icons'; +import { AddPermissions } from '@app/AccessManagement/RoleDetailContent/AddPermissions'; +import { RemovePermission } from '@app/AccessManagement/RoleDetailContent/RemovePermission'; + +const RolePermissions = (props: { name: string }) => { + const { t } = useTranslation(); + const { role, loading, error, setLoading } = useDescribeRole(props.name); + const [pagination, setPagination] = useState({ + page: 1, + perPage: 5 + }); + const [isAddPermissions, setIsAddPermissions] = useState(false); + const [removePermission, setIsRemovePermission] = useState(''); + const [searchValue, setSearchValue] = useState(''); + const [permissionRows, setPermissionRows] = useState([]); + const [filteredPermissions, setFilteredPermissions] = useState([]); + + useEffect(() => { + if (!role) { + return; + } + + if (searchValue.trim() !== '') { + setFilteredPermissions( + role.permissions.filter((perm) => perm.toLowerCase().includes(searchValue.toLowerCase())).sort() + ); + } else { + setFilteredPermissions(role.permissions.sort()); + } + + setPagination({ + ...pagination, + page: 1 + }); + }, [role, searchValue]); + + useEffect(() => { + if (!role) { + return; + } + const initSlice = (pagination.page - 1) * pagination.perPage; + setPermissionRows(filteredPermissions.slice(initSlice, initSlice + pagination.perPage)); + }, [filteredPermissions, pagination]); + + const columnNames = { + name: t('access-management.role.permission-name'), + description: t('access-management.role.permission-description') + }; + + const rowActions = (permission): IAction[] => [ + { + 'aria-label': 'removePermission-' + permission, + title: t('common.actions.remove'), + onClick: () => { + setIsRemovePermission(permission); + } + } + ]; + + const onSetPage = (_event, pageNumber) => { + setPagination({ + ...pagination, + page: pageNumber + }); + }; + + const onPerPageSelect = (_event, perPage) => { + setPagination({ + page: 1, + perPage: perPage + }); + }; + + const onSearchChange = (value: string) => { + setSearchValue(value); + }; + + const paginationComponent = ( + + ); + + const displayRows = () => { + if (!role || permissionRows.length == 0) { + return ( + + + + + + + {t('access-management.role.no-permissions-found')} + + {t('access-management.role.no-filtered-permissions-body')} + + + + + ); + } + + return ( + + {permissionRows.map((row) => ( + + + {row} + + + {PERMISSIONS_MAP.get(row) ? t(PERMISSIONS_MAP.get(row) as string) : row} + + {!role.implicit && role.permissions.length > 1 && ( + + {} + + )} + + ))} + + ); + }; + + const addPermissionButton = () => { + return ( + + ); + }; + + const displayImplicitRoleMessage = () => { + return ( + role?.implicit && ( + + ) + ); + }; + + if (loading) { + return ; + } + + if (error) { + return ; + } + + return ( + + {displayImplicitRoleMessage()} + + + + + onSearchChange(value)} + onSearch={(_event, value) => onSearchChange(value)} + onClear={() => setSearchValue('')} + /> + + {addPermissionButton()} + + {paginationComponent} + + + + + + + + + + {displayRows()} +
{columnNames.name}{columnNames.description}
+ + {paginationComponent} + + {role && ( + { + setIsAddPermissions(false); + setLoading(true); + }} + closeModal={() => { + setIsAddPermissions(false); + }} + /> + )} + {role && ( + { + setIsRemovePermission(''); + setLoading(true); + }} + closeModal={() => { + setIsRemovePermission(''); + }} + /> + )} +
+ ); +}; + +export { RolePermissions }; diff --git a/src/app/AccessManagement/RoleTableDisplay.tsx b/src/app/AccessManagement/RoleTableDisplay.tsx index 3597e56fb..f44347cfc 100644 --- a/src/app/AccessManagement/RoleTableDisplay.tsx +++ b/src/app/AccessManagement/RoleTableDisplay.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Bullseye, Button, @@ -14,13 +15,13 @@ import { Icon, Pagination, SearchInput, - Spinner, Title, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, - ToolbarItemVariant + ToolbarItemVariant, + Spinner } from '@patternfly/react-core'; import { ActionsColumn, IAction, Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { useTranslation } from 'react-i18next'; @@ -29,6 +30,8 @@ import { useFetchAvailableRoles } from '@app/services/rolesHook'; import { CreateRole } from '@app/AccessManagement/CreateRole'; import { global_spacer_sm, global_spacer_xl } from '@patternfly/react-tokens'; import { DeleteRole } from '@app/AccessManagement/DeleteRole'; +import { TableErrorState } from '@app/Common/TableErrorState'; +import { TableLoadingState } from '@app/Common/TableLoadingState'; const RoleTableDisplay = () => { const { t } = useTranslation(); @@ -89,7 +92,7 @@ const RoleTableDisplay = () => { const pagination = ( { const rowActions = (row): IAction[] => [ { 'aria-label': 'deleteRole', - title: t('access-management.roles.delete-action'), + title: t('common.actions.delete'), onClick: () => { setRoleToDelete(row.name); } @@ -120,7 +123,13 @@ const RoleTableDisplay = () => { )} - {row.name} + + {row.name} + { @@ -155,7 +164,7 @@ const RoleTableDisplay = () => { {roles.length == 0 - ? t('access-management.roles.no-roles-body') + ? t('access-management.roles.no-roles-body', { brandname: brandname }) : t('access-management.roles.no-filtered-roles-body')} @@ -164,6 +173,7 @@ const RoleTableDisplay = () => { ); }; + const createRoleButtonHelper = (isEmptyPage?: boolean) => { const emptyPageButtonProp = { style: { marginTop: global_spacer_xl.value } }; const normalPageButtonProps = { style: { marginLeft: global_spacer_sm.value } }; @@ -180,34 +190,6 @@ const RoleTableDisplay = () => { ); }; - if (loading) { - return ( - - - {t('access-management.roles.loading-roles')}} - icon={} - headingLevel="h4" - /> - - - ); - } - - if (error) { - return ( - - - {t('access-management.roles.loading-roles-error')}} - icon={} - headingLevel="h4" - /> - - - ); - } - const displayContent = () => { if (roles.length === 0) { return ( @@ -222,6 +204,15 @@ const RoleTableDisplay = () => { ); } + + if (loading) { + return ; + } + + if (error) { + return ; + } + return ( diff --git a/src/app/Caches/Create/CreateCacheWizard.tsx b/src/app/Caches/Create/CreateCacheWizard.tsx index b6adbaa0f..b9ca53c79 100644 --- a/src/app/Caches/Create/CreateCacheWizard.tsx +++ b/src/app/Caches/Create/CreateCacheWizard.tsx @@ -245,7 +245,7 @@ const CreateCacheWizard = (props: { cacheManager: CacheManager; create: boolean onClick={(event) => getPreviousStep(event, activeStep, onBack)} data-cy="wizardBackButton" > - {t('caches.create.back-button-label')} + {t('common.actions.back')} ); diff --git a/src/app/Caches/DetailCache.tsx b/src/app/Caches/DetailCache.tsx index 7b41abb19..53911879a 100644 --- a/src/app/Caches/DetailCache.tsx +++ b/src/app/Caches/DetailCache.tsx @@ -189,7 +189,7 @@ const DetailCache = (props: { cacheName: string }) => { search: location.search }} > - + diff --git a/src/app/Common/DataContainerBreadcrumb.tsx b/src/app/Common/DataContainerBreadcrumb.tsx index 35eb1b5c2..ddf824773 100644 --- a/src/app/Common/DataContainerBreadcrumb.tsx +++ b/src/app/Common/DataContainerBreadcrumb.tsx @@ -1,8 +1,15 @@ import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -const DataContainerBreadcrumb = (props: { currentPage: string; cacheName?: string }) => { +const DataContainerBreadcrumb = (props: { + currentPage: string; + parentPage?: string; + cacheName?: string; + label?: string; +}) => { + const { t } = useTranslation(); const addCacheName = () => { if (props.cacheName) { return ( @@ -20,17 +27,17 @@ const DataContainerBreadcrumb = (props: { currentPage: string; cacheName?: strin } return; }; - + const label = props.label ? props.label : 'cache-managers.title'; return ( - + - Data container + {t(label)} {addCacheName()} diff --git a/src/app/Common/SelectMultiWithChips.tsx b/src/app/Common/SelectMultiWithChips.tsx index 23f5df8c3..b92c739a8 100644 --- a/src/app/Common/SelectMultiWithChips.tsx +++ b/src/app/Common/SelectMultiWithChips.tsx @@ -24,6 +24,7 @@ const SelectMultiWithChips = (props: { selection: string[]; create?: boolean; closeOnSelect?: boolean; + readonly?: string[]; }) => { const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(''); @@ -192,6 +193,8 @@ const SelectMultiWithChips = (props: { ev.stopPropagation(); onSelect(selection); }} + isReadOnly={props.readonly && props.readonly.includes(selection)} + disabled={props.readonly && props.readonly.includes(selection)} > {selection} @@ -243,6 +246,7 @@ const SelectMultiWithChips = (props: { data-cy={`option-typeahead-${option.id !== undefined ? option.id : option.value}`} {...option} ref={null} + description={option.description} /> ))} diff --git a/src/app/Common/TableErrorState.tsx b/src/app/Common/TableErrorState.tsx index 9f1855a14..ad8bfff7e 100644 --- a/src/app/Common/TableErrorState.tsx +++ b/src/app/Common/TableErrorState.tsx @@ -2,9 +2,9 @@ import { Bullseye, EmptyState, EmptyStateBody, + EmptyStateHeader, EmptyStateIcon, - EmptyStateVariant, - EmptyStateHeader + EmptyStateVariant } from '@patternfly/react-core'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { global_danger_color_200 } from '@patternfly/react-tokens'; @@ -12,17 +12,15 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; const TableErrorState = (props: { error: string; detail?: string }) => { + const { t } = useTranslation(); const displayErrorBody = () => { if (props.detail) { return props.detail as string; } else { - return 'There was an error retrieving data.\n' + ' Check your connection and try again.'; + return t('common.loading-error-message'); } }; - const { t } = useTranslation(); - const brandname = t('brandname.brandname'); - return ( diff --git a/src/app/Common/TableLoadingState.tsx b/src/app/Common/TableLoadingState.tsx new file mode 100644 index 000000000..490fad976 --- /dev/null +++ b/src/app/Common/TableLoadingState.tsx @@ -0,0 +1,20 @@ +import { + Bullseye, + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + Spinner +} from '@patternfly/react-core'; +import * as React from 'react'; + +const TableLoadingState = (props: { message: string }) => { + return ( + + + } headingLevel="h4" /> + + + ); +}; +export { TableLoadingState }; diff --git a/src/app/assets/languages/en.json b/src/app/assets/languages/en.json index 457654cfe..89cb5c0ed 100644 --- a/src/app/assets/languages/en.json +++ b/src/app/assets/languages/en.json @@ -13,8 +13,12 @@ "actions": "Actions", "refresh": "Refresh", "back": "Back", - "cancel": "Cancel" - } + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "remove": "Remove" + }, + "loading-error-message": "There was an error retrieving data. Check your connection and try again." }, "layout": { "console-name": "Server Management Console", @@ -230,7 +234,6 @@ "templates-placeholder": "Select a cache template", "create-button-label": "Create", "next-button-label": "Next", - "back-button-label": "Back", "cancel-button-label": "Cancel", "download-button-label": "Download", "getting-started": { @@ -545,6 +548,7 @@ "actions": { "action-manage-indexes": "Manage indexes", "action-manage-backups": "Manage backups", + "refresh": "Refresh", "back": "Back" }, "entries": { @@ -885,8 +889,8 @@ "no-filtered-roles-body": "No roles match your search criteria.", "roles-hint-link": "Learn more in the {{brandname}} documentation", "loading-roles": "Loading roles", + "loading-roles-error": "Unexpected error loading roles", "create-button": "Create role", - "delete-action": "Delete", "no-roles-status": "No roles", "permission-all": "All the permissions", "permission-admin": "Allows performing \"administrative\" operations", @@ -904,8 +908,6 @@ "permission-none": "No permissions", "modal-create-title": "Create role", "modal-create-description": "Create a new role.", - "modal-save-action": "Save", - "modal-cancel-button": "Cancel", "modal-role-name": "Name", "modal-role-description": "Description", "modal-permissions": "Permissions", @@ -918,6 +920,29 @@ "modal-delete-description-2": "You can always recreate the role.", "delete-success": "Role {{name}} has been deleted", "delete-error": "Unexpected error deleting the role {{name}}" + }, + "role": { + "breadcrumb": "Detail of role {{roleName}}", + "actions": "Actions", + "tab-general": "General settings", + "tab-permissions": "Permissions", + "tab-caches": "Accessible caches", + "loading": "Loading role {{roleName}}", + "error": "An error occurred while retrieving the role {{roleName}}", + "implicit-warning": "The general settings for predefined roles are not editable.", + "permission-name": "Permission name", + "permission-description": "Description", + "add-permission-button": "Add permission", + "permissions-search-placeholder": "Filter by name", + "no-permissions-found": "No permission found", + "no-filtered-permissions-body": "No permission match your search criteria.", + "permissions-implicit-warning": "The permissions for predefined roles are not editable.", + "update-success": "Role {{name}} has been updated", + "update-error": "Unexpected error updating the role {{name}}", + "modal-add-permission-title": "Add permissions", + "modal-remove-permission-title": "Remove permission {{name}}", + "modal-remove-permission-description-1": "Are you sure you want to remove this permission from role {{roleName}}? After removing, the role's access will be changed.", + "modal-remove-permission-description-2": "You can always add the role permission again." } } } diff --git a/src/app/routes.tsx b/src/app/routes.tsx index 0de59723f..ad388ed7c 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -19,6 +19,7 @@ import { ConsoleServices } from '@services/ConsoleServices'; import { ConsoleACL } from '@services/securityService'; import { NotAuthorized } from '@app/NotAuthorized/NotAuthorized'; import { NotFound } from '@app/NotFound/NotFound'; +import { RoleDetail } from '@app/AccessManagement/RoleDetail'; let routeFocusTimer: number; @@ -176,7 +177,8 @@ const routes: IAppRoute[] = [ path: '/access-management', title: 'Access Management', menu: true, - admin: true + admin: true, + subRoutes: ['role'] }, { component: ConnectedClients, @@ -186,6 +188,15 @@ const routes: IAppRoute[] = [ title: 'Connected Clients', menu: true, admin: true + }, + { + component: RoleDetail, + exact: true, + label: 'Role detail', + path: '/access-management/role/:roleName', + title: 'Role detail', + menu: false, + admin: true } ]; diff --git a/src/app/services/rolesHook.ts b/src/app/services/rolesHook.ts index 6330c5be9..c78ce3a76 100644 --- a/src/app/services/rolesHook.ts +++ b/src/app/services/rolesHook.ts @@ -68,6 +68,59 @@ export function useFetchAvailableRoles() { }; } +export function useUpdateRole(roleName: string, roleDescription: string, permissions: string[], call: () => void) { + const { addAlert } = useApiAlert(); + const { t } = useTranslation(); + + const onUpdateRole = () => { + ConsoleServices.security() + .updateRole( + roleName, + roleDescription, + permissions, + t('access-management.role.update-success', { name: roleName }), + t('access-management.role.update-error', { name: roleName }) + ) + .then((actionResponse) => { + addAlert(actionResponse); + }) + .finally(() => call()); + }; + return { + onUpdateRole + }; +} + +export function useRemovePermission(roleName: string, permission: string, permissions: string[], call: () => void) { + const { addAlert } = useApiAlert(); + const { t } = useTranslation(); + + const onRemovePermission = () => { + const perms: string[] = []; + permissions.forEach((perm) => { + if (perm !== permission) { + perms.push(perm); + } + }); + + ConsoleServices.security() + .updateRole( + roleName, + '', + perms, + t('access-management.role.update-success', { name: roleName }), + t('access-management.role.update-error', { name: roleName }) + ) + .then((actionResponse) => { + addAlert(actionResponse); + }) + .finally(() => call()); + }; + return { + onRemovePermission + }; +} + export function useCreateRole(roleName: string, roleDescription: string, permissions: string[], call: () => void) { const { addAlert } = useApiAlert(); @@ -104,6 +157,34 @@ export function useDeleteRole(roleName: string, call: () => void) { }; } +export function useDescribeRole(roleName: string) { + const [role, setRole] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (loading) { + ConsoleServices.security() + .describeRole(roleName) + .then((either) => { + if (either.isRight()) { + setRole(either.value); + } else { + setError(either.value.message); + } + }) + .finally(() => setLoading(false)); + } + }, [loading]); + + return { + role, + loading, + error, + setLoading + }; +} + export function useFlushCache(call: () => void) { const { addAlert } = useApiAlert(); const { t } = useTranslation(); diff --git a/src/services/displayUtils.ts b/src/services/displayUtils.ts index 44b8664b1..27f293b1f 100644 --- a/src/services/displayUtils.ts +++ b/src/services/displayUtils.ts @@ -470,7 +470,9 @@ class DisplayUtils { // Try parse and stringify try { return JSON.stringify(JSON.parse(content), null, 2); - } catch (err) {} + } catch (err) { + /* empty */ + } } return content as string; diff --git a/src/services/infinispanRefData.ts b/src/services/infinispanRefData.ts index f98c95662..abf19f445 100644 --- a/src/services/infinispanRefData.ts +++ b/src/services/infinispanRefData.ts @@ -247,3 +247,24 @@ export enum RoleFilterOption { cacheManagerPermissions = 'Cache Manager Permissions', cachePermissions = 'Cache Permissions' } + +export enum Permission { + ALL = 'all' +} + +export const PERMISSIONS_MAP = new Map([ + ['ALL', 'access-management.roles.permission-all'], + ['ADMIN', 'access-management.roles.permission-admin'], + ['ALL_READ', 'access-management.roles.permission-all-read'], + ['READ', 'access-management.roles.permission-read'], + ['BULK_READ', 'access-management.roles.permission-bulk-read'], + ['ALL_WRITE', 'access-management.roles.permission-all-write'], + ['WRITE', 'access-management.roles.permission-write'], + ['BULK_WRITE', 'access-management.roles.permission-bulk-write'], + ['MONITOR', 'access-management.roles.permission-monitor'], + ['CREATE', 'access-management.roles.permission-create'], + ['EXEC', 'access-management.roles.permission-exec'], + ['LISTEN', 'access-management.roles.permission-listen'], + ['LIFECYCLE', 'access-management.roles.permission-lifecycle'], + ['NONE', 'access-management.roles.permission-none'] +]); diff --git a/src/services/securityService.ts b/src/services/securityService.ts index 8b517a895..8d3b86871 100644 --- a/src/services/securityService.ts +++ b/src/services/securityService.ts @@ -178,6 +178,23 @@ export class SecurityService { ); } + /** + * Retrieve security roles + * + */ + public async describeRole(roleName: string): Promise> { + return this.fetchCaller.get( + this.endpoint + '/permissions/' + roleName, + (data) => + { + name: roleName, + description: data.description, + permissions: data.permissions, + implicit: data.implicit + } + ); + } + /** * Created a new role * @param roleName @@ -210,6 +227,30 @@ export class SecurityService { }); } + /** + * updates an existing role + * + * @param roleName + * @param roleDescription + * @param permissions + * @param messageOk + * @param messageError + */ + public async updateRole( + roleName: string, + roleDescription: string, + permissions: string[], + messageOk: string, + messageError: string + ) { + return this.fetchCaller.put({ + url: this.endpoint + '/permissions/' + roleName + '?' + permissions.map((p) => 'permission=' + p).join('&'), + successMessage: messageOk, + errorMessage: messageError, + body: roleDescription + }); + } + /** * Flush security cache * @param messageOk