[SECURITY] add clone functionality to role mapping (#118434) (#118923)

* add clobe to role mapping and update functionalit in role to match UX

* fix some I

* fix jest test + fix table selection when canceling deletion

* add tests around clone action in role mapping

* fix i18n

* remove i18n

* review Greg I

* fix styling + name

* add explaination

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Kibana Machine 2021-11-17 15:29:50 -05:00 committed by GitHub
parent f537c11b86
commit bbc00de080
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 430 additions and 181 deletions

View file

@ -9,3 +9,8 @@ export const EDIT_ROLE_MAPPING_PATH = `/edit`;
export const getEditRoleMappingHref = (roleMappingName: string) =>
`${EDIT_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`;
export const CLONE_ROLE_MAPPING_PATH = `/clone`;
export const getCloneRoleMappingHref = (roleMappingName: string) =>
`${CLONE_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`;

View file

@ -24,10 +24,12 @@ interface Props {
export type DeleteRoleMappings = (
roleMappings: RoleMapping[],
onSuccess?: OnSuccessCallback
onSuccess?: OnSuccessCallback,
onCancel?: OnCancelCallback
) => void;
type OnSuccessCallback = (deletedRoleMappings: string[]) => void;
type OnCancelCallback = () => void;
export const DeleteProvider: React.FunctionComponent<Props> = ({
roleMappingsAPI,
@ -39,10 +41,12 @@ export const DeleteProvider: React.FunctionComponent<Props> = ({
const [isDeleteInProgress, setIsDeleteInProgress] = useState(false);
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);
const onCancelCallback = useRef<OnCancelCallback | null>(null);
const deleteRoleMappingsPrompt: DeleteRoleMappings = (
roleMappingsToDelete,
onSuccess = () => undefined
onSuccess = () => undefined,
onCancel = () => undefined
) => {
if (!roleMappingsToDelete || !roleMappingsToDelete.length) {
throw new Error('No Role Mappings specified for delete');
@ -50,6 +54,7 @@ export const DeleteProvider: React.FunctionComponent<Props> = ({
setIsModalOpen(true);
setRoleMappings(roleMappingsToDelete);
onSuccessCallback.current = onSuccess;
onCancelCallback.current = onCancel;
};
const closeModal = () => {
@ -57,6 +62,13 @@ export const DeleteProvider: React.FunctionComponent<Props> = ({
setRoleMappings([]);
};
const handleCancelModel = () => {
closeModal();
if (onCancelCallback.current) {
onCancelCallback.current();
}
};
const deleteRoleMappings = async () => {
let result;
@ -161,7 +173,7 @@ export const DeleteProvider: React.FunctionComponent<Props> = ({
}
)
}
onCancel={closeModal}
onCancel={handleCancelModel}
onConfirm={deleteRoleMappings}
cancelButtonText={i18n.translate(
'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.cancelButtonLabel',

View file

@ -39,6 +39,7 @@ describe('EditRoleMappingPage', () => {
return mountWithIntl(
<KibanaContextProvider services={coreStart}>
<EditRoleMappingPage
action="edit"
name={name}
roleMappingsAPI={roleMappingsAPI}
rolesAPIClient={rolesAPI}

View file

@ -51,6 +51,7 @@ interface State {
}
interface Props {
action: 'edit' | 'clone';
name?: string;
roleMappingsAPI: PublicMethodsOf<RoleMappingsAPIClient>;
rolesAPIClient: PublicMethodsOf<RolesAPIClient>;
@ -295,13 +296,17 @@ export class EditRoleMappingPage extends Component<Props, State> {
});
};
private editingExistingRoleMapping = () => typeof this.props.name === 'string';
private editingExistingRoleMapping = () =>
typeof this.props.name === 'string' && this.props.action === 'edit';
private cloningExistingRoleMapping = () =>
typeof this.props.name === 'string' && this.props.action === 'clone';
private async loadAppData() {
try {
const [features, roleMapping] = await Promise.all([
this.props.roleMappingsAPI.checkRoleMappingFeatures(),
this.editingExistingRoleMapping()
this.editingExistingRoleMapping() || this.cloningExistingRoleMapping()
? this.props.roleMappingsAPI.getRoleMapping(this.props.name!)
: Promise.resolve({
name: '',
@ -327,7 +332,10 @@ export class EditRoleMappingPage extends Component<Props, State> {
hasCompatibleRealms,
canUseStoredScripts,
canUseInlineScripts,
roleMapping,
roleMapping: {
...roleMapping,
name: this.cloningExistingRoleMapping() ? '' : roleMapping.name,
},
});
} catch (e) {
this.props.notifications.toasts.addDanger({

View file

@ -10,7 +10,7 @@ import { act } from '@testing-library/react';
import React from 'react';
import { findTestSubject, mountWithIntl, nextTick } from '@kbn/test/jest';
import type { CoreStart, ScopedHistory } from 'src/core/public';
import type { CoreStart } from 'src/core/public';
import { coreMock, scopedHistoryMock } from 'src/core/public/mocks';
import { KibanaContextProvider } from 'src/plugins/kibana_react/public';
@ -21,7 +21,7 @@ import { EmptyPrompt } from './empty_prompt';
import { RoleMappingsGridPage } from './role_mappings_grid_page';
describe('RoleMappingsGridPage', () => {
let history: ScopedHistory;
let history: ReturnType<typeof scopedHistoryMock.create>;
let coreStart: CoreStart;
const renderView = (
@ -44,6 +44,7 @@ describe('RoleMappingsGridPage', () => {
beforeEach(() => {
history = scopedHistoryMock.create();
history.createHref.mockImplementation((location) => location.pathname!);
coreStart = coreMock.createStart();
});
@ -188,6 +189,7 @@ describe('RoleMappingsGridPage', () => {
expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(1);
expect(roleMappingsAPI.deleteRoleMappings).not.toHaveBeenCalled();
findTestSubject(wrapper, `euiCollapsedItemActionsButton`).simulate('click');
findTestSubject(wrapper, `deleteRoleMappingButton-some-realm`).simulate('click');
expect(findTestSubject(wrapper, 'deleteRoleMappingConfirmationModal')).toHaveLength(1);
@ -246,4 +248,55 @@ describe('RoleMappingsGridPage', () => {
`"The kibana_user role is deprecated. I don't like you."`
);
});
it('renders role mapping actions as appropriate', async () => {
const roleMappingsAPI = roleMappingsAPIClientMock.create();
roleMappingsAPI.getRoleMappings.mockResolvedValue([
{
name: 'some-realm',
enabled: true,
roles: ['superuser'],
rules: { field: { username: '*' } },
},
]);
roleMappingsAPI.checkRoleMappingFeatures.mockResolvedValue({
canManageRoleMappings: true,
hasCompatibleRealms: true,
});
roleMappingsAPI.deleteRoleMappings.mockResolvedValue([
{
name: 'some-realm',
success: true,
},
]);
const wrapper = renderView(roleMappingsAPI);
await nextTick();
wrapper.update();
const editButton = wrapper.find(
'EuiButtonEmpty[data-test-subj="editRoleMappingButton-some-realm"]'
);
expect(editButton).toHaveLength(1);
expect(editButton.prop('href')).toBe('/edit/some-realm');
const cloneButton = wrapper.find(
'EuiButtonEmpty[data-test-subj="cloneRoleMappingButton-some-realm"]'
);
expect(cloneButton).toHaveLength(1);
expect(cloneButton.prop('href')).toBe('/clone/some-realm');
const actionMenuButton = wrapper.find(
'EuiButtonIcon[data-test-subj="euiCollapsedItemActionsButton"]'
);
expect(actionMenuButton).toHaveLength(1);
actionMenuButton.simulate('click');
wrapper.update();
const deleteButton = wrapper.find(
'EuiButtonEmpty[data-test-subj="deleteRoleMappingButton-some-realm"]'
);
expect(deleteButton).toHaveLength(1);
});
});

View file

@ -6,7 +6,7 @@
*/
import {
EuiButton,
EuiButtonIcon,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
@ -32,18 +32,23 @@ import type {
import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public';
import type { Role, RoleMapping } from '../../../../common/model';
import { DisabledBadge, EnabledBadge } from '../../badges';
import { EDIT_ROLE_MAPPING_PATH, getEditRoleMappingHref } from '../../management_urls';
import {
EDIT_ROLE_MAPPING_PATH,
getCloneRoleMappingHref,
getEditRoleMappingHref,
} from '../../management_urls';
import { RoleTableDisplay } from '../../role_table_display';
import type { RolesAPIClient } from '../../roles';
import { ActionsEuiTableFormatting } from '../../table_utils';
import {
DeleteProvider,
NoCompatibleRealms,
PermissionDenied,
SectionLoading,
} from '../components';
import type { DeleteRoleMappings } from '../components/delete_provider/delete_provider';
import type { RoleMappingsAPIClient } from '../role_mappings_api_client';
import { EmptyPrompt } from './empty_prompt';
interface Props {
rolesAPIClient: PublicMethodsOf<RolesAPIClient>;
roleMappingsAPI: PublicMethodsOf<RoleMappingsAPIClient>;
@ -63,6 +68,7 @@ interface State {
}
export class RoleMappingsGridPage extends Component<Props, State> {
private tableRef: React.RefObject<EuiInMemoryTable<RoleMapping>>;
constructor(props: any) {
super(props);
this.state = {
@ -73,6 +79,7 @@ export class RoleMappingsGridPage extends Component<Props, State> {
selectedItems: [],
error: undefined,
};
this.tableRef = React.createRef();
}
public componentDidMount() {
@ -224,7 +231,13 @@ export class RoleMappingsGridPage extends Component<Props, State> {
{(deleteRoleMappingsPrompt) => {
return (
<EuiButton
onClick={() => deleteRoleMappingsPrompt(selectedItems, this.onRoleMappingsDeleted)}
onClick={() =>
deleteRoleMappingsPrompt(
selectedItems,
this.onRoleMappingsDeleted,
this.onRoleMappingsDeleteCancel
)
}
color="danger"
data-test-subj="bulkDeleteActionButton"
>
@ -260,27 +273,40 @@ export class RoleMappingsGridPage extends Component<Props, State> {
};
return (
<EuiInMemoryTable
items={roleMappings!}
itemId="name"
columns={this.getColumnConfig()}
search={search}
sorting={sorting}
selection={selection}
pagination={pagination}
loading={loadState === 'loadingTable'}
message={message}
isSelectable={true}
rowProps={() => {
return {
'data-test-subj': 'roleMappingRow',
};
<DeleteProvider
roleMappingsAPI={this.props.roleMappingsAPI}
notifications={this.props.notifications}
>
{(deleteRoleMappingPrompt) => {
return (
<ActionsEuiTableFormatting>
<EuiInMemoryTable
items={roleMappings!}
itemId="name"
columns={this.getColumnConfig(deleteRoleMappingPrompt)}
hasActions={true}
search={search}
sorting={sorting}
selection={selection}
pagination={pagination}
loading={loadState === 'loadingTable'}
message={message}
isSelectable={true}
ref={this.tableRef}
rowProps={() => {
return {
'data-test-subj': 'roleMappingRow',
};
}}
/>
</ActionsEuiTableFormatting>
);
}}
/>
</DeleteProvider>
);
};
private getColumnConfig = () => {
private getColumnConfig = (deleteRoleMappingPrompt: DeleteRoleMappings) => {
const config = [
{
field: 'name',
@ -357,72 +383,97 @@ export class RoleMappingsGridPage extends Component<Props, State> {
}),
actions: [
{
isPrimary: true,
render: (record: RoleMapping) => {
const title = i18n.translate(
'xpack.security.management.roleMappings.actionCloneTooltip',
{ defaultMessage: 'Clone' }
);
const label = i18n.translate(
'xpack.security.management.roleMappings.actionCloneAriaLabel',
{
defaultMessage: `Clone '{name}'`,
values: { name: record.name },
}
);
return (
<EuiToolTip
content={i18n.translate(
'xpack.security.management.roleMappings.actionEditTooltip',
{ defaultMessage: 'Edit' }
)}
>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.security.management.roleMappings.actionEditAriaLabel',
{
defaultMessage: `Edit '{name}'`,
values: { name: record.name },
}
)}
iconType="pencil"
<EuiToolTip content={title}>
<EuiButtonEmpty
aria-label={label}
iconType="copy"
color="primary"
data-test-subj={`editRoleMappingButton-${record.name}`}
data-test-subj={`cloneRoleMappingButton-${record.name}`}
disabled={this.state.selectedItems.length >= 1}
{...reactRouterNavigate(
this.props.history,
getEditRoleMappingHref(record.name)
getCloneRoleMappingHref(record.name)
)}
/>
>
{title}
</EuiButtonEmpty>
</EuiToolTip>
);
},
},
{
render: (record: RoleMapping) => {
const title = i18n.translate(
'xpack.security.management.roleMappings.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
);
const label = i18n.translate(
'xpack.security.management.roleMappings.actionDeleteAriaLabel',
{
defaultMessage: `Delete '{name}'`,
values: { name: record.name },
}
);
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<DeleteProvider
roleMappingsAPI={this.props.roleMappingsAPI}
notifications={this.props.notifications}
>
{(deleteRoleMappingPrompt) => {
return (
<EuiToolTip
content={i18n.translate(
'xpack.security.management.roleMappings.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
)}
>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.security.management.roleMappings.actionDeleteAriaLabel',
{
defaultMessage: `Delete '{name}'`,
values: { name: record.name },
}
)}
iconType="trash"
color="danger"
data-test-subj={`deleteRoleMappingButton-${record.name}`}
onClick={() =>
deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted)
}
/>
</EuiToolTip>
);
}}
</DeleteProvider>
</EuiFlexItem>
</EuiFlexGroup>
<EuiToolTip content={title}>
<EuiButtonEmpty
aria-label={label}
iconType="trash"
color="danger"
data-test-subj={`deleteRoleMappingButton-${record.name}`}
disabled={this.state.selectedItems.length >= 1}
onClick={() => deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted)}
>
{title}
</EuiButtonEmpty>
</EuiToolTip>
);
},
},
{
isPrimary: true,
render: (record: RoleMapping) => {
const label = i18n.translate(
'xpack.security.management.roleMappings.actionEditAriaLabel',
{
defaultMessage: `Edit '{name}'`,
values: { name: record.name },
}
);
const title = i18n.translate(
'xpack.security.management.roleMappings.actionEditTooltip',
{ defaultMessage: 'Edit' }
);
return (
<EuiToolTip content={title}>
<EuiButtonEmpty
aria-label={label}
iconType="pencil"
color="primary"
data-test-subj={`editRoleMappingButton-${record.name}`}
disabled={this.state.selectedItems.length >= 1}
{...reactRouterNavigate(
this.props.history,
getEditRoleMappingHref(record.name)
)}
>
{title}
</EuiButtonEmpty>
</EuiToolTip>
);
},
},
@ -438,6 +489,10 @@ export class RoleMappingsGridPage extends Component<Props, State> {
}
};
private onRoleMappingsDeleteCancel = () => {
this.tableRef.current?.setSelection([]);
};
private async checkPrivileges() {
try {
const { canManageRoleMappings, hasCompatibleRealms } =

View file

@ -100,7 +100,7 @@ describe('roleMappingsManagementApp', () => {
expect(docTitle.reset).not.toHaveBeenCalled();
expect(container).toMatchInlineSnapshot(`
<div>
Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
Role Mapping Edit Page: {"action":"edit","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
</div>
`);
@ -128,7 +128,7 @@ describe('roleMappingsManagementApp', () => {
expect(docTitle.reset).not.toHaveBeenCalled();
expect(container).toMatchInlineSnapshot(`
<div>
Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}}
Role Mapping Edit Page: {"action":"edit","name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}}
</div>
`);

View file

@ -56,7 +56,7 @@ export const roleMappingsManagementApp = Object.freeze({
const roleMappingsAPIClient = new RoleMappingsAPIClient(core.http);
const EditRoleMappingsPageWithBreadcrumbs = () => {
const EditRoleMappingsPageWithBreadcrumbs = ({ action }: { action: 'edit' | 'clone' }) => {
const { name } = useParams<{ name?: string }>();
// Additional decoding is a workaround for a bug in react-router's version of the `history` module.
@ -64,7 +64,7 @@ export const roleMappingsManagementApp = Object.freeze({
const decodedName = name ? tryDecodeURIComponent(name) : undefined;
const breadcrumbObj =
name && decodedName
action === 'edit' && name && decodedName
? { text: decodedName, href: `/edit/${encodeURIComponent(name)}` }
: {
text: i18n.translate('xpack.security.roleMappings.createBreadcrumb', {
@ -75,6 +75,7 @@ export const roleMappingsManagementApp = Object.freeze({
return (
<Breadcrumb text={breadcrumbObj.text} href={breadcrumbObj.href}>
<EditRoleMappingPage
action={action}
name={decodedName}
roleMappingsAPI={roleMappingsAPIClient}
rolesAPIClient={new RolesAPIClient(core.http)}
@ -105,7 +106,10 @@ export const roleMappingsManagementApp = Object.freeze({
/>
</Route>
<Route path="/edit/:name?">
<EditRoleMappingsPageWithBreadcrumbs />
<EditRoleMappingsPageWithBreadcrumbs action="edit" />
</Route>
<Route path="/clone/:name">
<EditRoleMappingsPageWithBreadcrumbs action="clone" />
</Route>
</Breadcrumb>
</BreadcrumbsProvider>

View file

@ -144,31 +144,33 @@ describe('<RolesGridPage />', () => {
expect(wrapper.find(PermissionDenied)).toHaveLength(0);
let editButton = wrapper.find('EuiButtonIcon[data-test-subj="edit-role-action-test-role-1"]');
let editButton = wrapper.find('EuiButtonEmpty[data-test-subj="edit-role-action-test-role-1"]');
expect(editButton).toHaveLength(1);
expect(editButton.prop('href')).toBe('/edit/test-role-1');
editButton = wrapper.find(
'EuiButtonIcon[data-test-subj="edit-role-action-special%chars%role"]'
'EuiButtonEmpty[data-test-subj="edit-role-action-special%chars%role"]'
);
expect(editButton).toHaveLength(1);
expect(editButton.prop('href')).toBe('/edit/special%25chars%25role');
let cloneButton = wrapper.find('EuiButtonIcon[data-test-subj="clone-role-action-test-role-1"]');
let cloneButton = wrapper.find(
'EuiButtonEmpty[data-test-subj="clone-role-action-test-role-1"]'
);
expect(cloneButton).toHaveLength(1);
expect(cloneButton.prop('href')).toBe('/clone/test-role-1');
cloneButton = wrapper.find(
'EuiButtonIcon[data-test-subj="clone-role-action-special%chars%role"]'
'EuiButtonEmpty[data-test-subj="clone-role-action-special%chars%role"]'
);
expect(cloneButton).toHaveLength(1);
expect(cloneButton.prop('href')).toBe('/clone/special%25chars%25role');
expect(
wrapper.find('EuiButtonIcon[data-test-subj="edit-role-action-disabled-role"]')
wrapper.find('EuiButtonEmpty[data-test-subj="edit-role-action-disabled-role"]')
).toHaveLength(1);
expect(
wrapper.find('EuiButtonIcon[data-test-subj="clone-role-action-disabled-role"]')
wrapper.find('EuiButtonEmpty[data-test-subj="clone-role-action-disabled-role"]')
).toHaveLength(1);
});

View file

@ -8,7 +8,7 @@
import type { EuiBasicTableColumn, EuiSwitchEvent } from '@elastic/eui';
import {
EuiButton,
EuiButtonIcon,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
@ -17,6 +17,7 @@ import {
EuiSpacer,
EuiSwitch,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import _ from 'lodash';
import React, { Component } from 'react';
@ -36,6 +37,7 @@ import {
isRoleReserved,
} from '../../../../common/model';
import { DeprecatedBadge, DisabledBadge, ReservedBadge } from '../../badges';
import { ActionsEuiTableFormatting } from '../../table_utils';
import type { RolesAPIClient } from '../roles_api_client';
import { ConfirmDelete } from './confirm_delete';
import { PermissionDenied } from './permission_denied';
@ -61,6 +63,7 @@ const getRoleManagementHref = (action: 'edit' | 'clone', roleName?: string) => {
};
export class RolesGridPage extends Component<Props, State> {
private tableRef: React.RefObject<EuiInMemoryTable<Role>>;
constructor(props: Props) {
super(props);
this.state = {
@ -72,6 +75,7 @@ export class RolesGridPage extends Component<Props, State> {
permissionDenied: false,
includeReservedRoles: true,
};
this.tableRef = React.createRef();
}
public componentDidMount() {
@ -129,53 +133,56 @@ export class RolesGridPage extends Component<Props, State> {
/>
) : null}
<EuiInMemoryTable
itemId="name"
responsive={false}
columns={this.getColumnConfig()}
hasActions={true}
selection={{
selectable: (role: Role) => !role.metadata || !role.metadata._reserved,
selectableMessage: (selectable: boolean) => (!selectable ? 'Role is reserved' : ''),
onSelectionChange: (selection: Role[]) => this.setState({ selection }),
}}
pagination={{
initialPageSize: 20,
pageSizeOptions: [10, 20, 30, 50, 100],
}}
items={this.state.visibleRoles}
loading={roles.length === 0}
search={{
toolsLeft: this.renderToolsLeft(),
toolsRight: this.renderToolsRight(),
box: {
incremental: true,
'data-test-subj': 'searchRoles',
},
onChange: (query: Record<string, any>) => {
this.setState({
filter: query.queryText,
visibleRoles: this.getVisibleRoles(
this.state.roles,
query.queryText,
this.state.includeReservedRoles
),
});
},
}}
sorting={{
sort: {
field: 'name',
direction: 'asc',
},
}}
rowProps={() => {
return {
'data-test-subj': 'roleRow',
};
}}
isSelectable
/>
<ActionsEuiTableFormatting>
<EuiInMemoryTable
itemId="name"
responsive={false}
columns={this.getColumnConfig()}
hasActions={true}
selection={{
selectable: (role: Role) => !role.metadata || !role.metadata._reserved,
selectableMessage: (selectable: boolean) => (!selectable ? 'Role is reserved' : ''),
onSelectionChange: (selection: Role[]) => this.setState({ selection }),
}}
pagination={{
initialPageSize: 20,
pageSizeOptions: [10, 20, 30, 50, 100],
}}
items={this.state.visibleRoles}
loading={roles.length === 0}
search={{
toolsLeft: this.renderToolsLeft(),
toolsRight: this.renderToolsRight(),
box: {
incremental: true,
'data-test-subj': 'searchRoles',
},
onChange: (query: Record<string, any>) => {
this.setState({
filter: query.queryText,
visibleRoles: this.getVisibleRoles(
this.state.roles,
query.queryText,
this.state.includeReservedRoles
),
});
},
}}
sorting={{
sort: {
field: 'name',
direction: 'asc',
},
}}
ref={this.tableRef}
rowProps={(role: Role) => {
return {
'data-test-subj': `roleRow`,
};
}}
isSelectable
/>
</ActionsEuiTableFormatting>
</>
);
};
@ -218,49 +225,99 @@ export class RolesGridPage extends Component<Props, State> {
}),
width: '150px',
actions: [
{
available: (role: Role) => !isRoleReadOnly(role),
render: (role: Role) => {
const title = i18n.translate('xpack.security.management.roles.editRoleActionName', {
defaultMessage: `Edit {roleName}`,
values: { roleName: role.name },
});
return (
<EuiButtonIcon
aria-label={title}
data-test-subj={`edit-role-action-${role.name}`}
title={title}
color={'primary'}
iconType={'pencil'}
{...reactRouterNavigate(
this.props.history,
getRoleManagementHref('edit', role.name)
)}
/>
);
},
},
{
available: (role: Role) => !isRoleReserved(role),
isPrimary: true,
render: (role: Role) => {
const title = i18n.translate('xpack.security.management.roles.cloneRoleActionName', {
defaultMessage: `Clone`,
});
const label = i18n.translate('xpack.security.management.roles.cloneRoleActionLabel', {
defaultMessage: `Clone {roleName}`,
values: { roleName: role.name },
});
return (
<EuiButtonIcon
aria-label={title}
data-test-subj={`clone-role-action-${role.name}`}
title={title}
color={'primary'}
iconType={'copy'}
{...reactRouterNavigate(
this.props.history,
getRoleManagementHref('clone', role.name)
)}
/>
<EuiToolTip content={title}>
<EuiButtonEmpty
aria-label={label}
color={'primary'}
data-test-subj={`clone-role-action-${role.name}`}
disabled={this.state.selection.length >= 1}
iconType={'copy'}
{...reactRouterNavigate(
this.props.history,
getRoleManagementHref('clone', role.name)
)}
>
{title}
</EuiButtonEmpty>
</EuiToolTip>
);
},
},
{
available: (role: Role) => !role.metadata || !role.metadata._reserved,
render: (role: Role) => {
const title = i18n.translate('xpack.security.management.roles.deleteRoleActionName', {
defaultMessage: `Delete`,
});
const label = i18n.translate(
'xpack.security.management.roles.deleteRoleActionLabel',
{
defaultMessage: `Delete {roleName}`,
values: { roleName: role.name },
}
);
return (
<EuiToolTip content={title}>
<EuiButtonEmpty
aria-label={label}
color={'danger'}
data-test-subj={`delete-role-action-${role.name}`}
disabled={this.state.selection.length >= 1}
iconType={'trash'}
onClick={() => this.deleteOneRole(role)}
>
{title}
</EuiButtonEmpty>
</EuiToolTip>
);
},
},
{
available: (role: Role) => !isRoleReadOnly(role),
enable: () => this.state.selection.length === 0,
isPrimary: true,
render: (role: Role) => {
const title = i18n.translate('xpack.security.management.roles.editRoleActionName', {
defaultMessage: `Edit`,
});
const label = i18n.translate('xpack.security.management.roles.editRoleActionLabel', {
defaultMessage: `Edit {roleName}`,
values: { roleName: role.name },
});
return (
<EuiToolTip content={title}>
<EuiButtonEmpty
aria-label={label}
color={'primary'}
data-test-subj={`edit-role-action-${role.name}`}
disabled={this.state.selection.length >= 1}
iconType={'pencil'}
{...reactRouterNavigate(
this.props.history,
getRoleManagementHref('edit', role.name)
)}
>
{title}
</EuiButtonEmpty>
</EuiToolTip>
);
},
},
@ -337,6 +394,13 @@ export class RolesGridPage extends Component<Props, State> {
this.loadRoles();
};
private deleteOneRole = (roleToDelete: Role) => {
this.setState({
selection: [roleToDelete],
showDeleteConfirmation: true,
});
};
private async loadRoles() {
try {
const roles = await this.props.rolesAPIClient.getRoles();
@ -385,6 +449,7 @@ export class RolesGridPage extends Component<Props, State> {
</EuiButton>
);
}
private renderToolsRight() {
return (
<EuiSwitch
@ -401,6 +466,7 @@ export class RolesGridPage extends Component<Props, State> {
);
}
private onCancelDelete = () => {
this.setState({ showDeleteConfirmation: false });
this.setState({ showDeleteConfirmation: false, selection: [] });
this.tableRef.current?.setSelection([]);
};
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { css } from '@emotion/react';
import type { ReactNode } from 'react';
import React from 'react';
interface ActionsEuiTableFormattingProps {
children: ReactNode;
}
/*
* Notes to future engineer:
* We created this component because as this time EUI actions table where not allowing to pass
* props href on an action. In our case, we want our actions to work with href
* and onClick. Then the problem is that the design did not match with EUI example, therefore
* we are doing some css magic to only have icon showing up when user is hovering a row
*/
export const ActionsEuiTableFormatting = React.memo<ActionsEuiTableFormattingProps>(
({ children }) => (
<div
css={css`
.euiTableRowCell--hasActions .euiButtonEmpty .euiButtonContent {
padding: 0px 0px;
.euiButtonEmpty__text {
display: none;
}
}
`}
>
{children}
</div>
)
);

View file

@ -19857,7 +19857,6 @@
"xpack.security.management.roleMappings.rolesColumnName": "ロール",
"xpack.security.management.roleMappingsTitle": "ロールマッピング",
"xpack.security.management.roles.actionsColumnName": "アクション",
"xpack.security.management.roles.cloneRoleActionName": "{roleName} を複製",
"xpack.security.management.roles.confirmDelete.cancelButtonLabel": "キャンセル",
"xpack.security.management.roles.confirmDelete.deleteButtonLabel": "削除",
"xpack.security.management.roles.confirmDelete.removingRolesDescription": "これらのロールを削除しようとしています:",
@ -19868,7 +19867,6 @@
"xpack.security.management.roles.deleteSelectedRolesButtonLabel": "ロール {numSelected} {numSelected, plural, one { } other {}} を削除しました",
"xpack.security.management.roles.deletingRolesWarningMessage": "この操作は元に戻すことができません。",
"xpack.security.management.roles.deniedPermissionTitle": "ロールを管理するにはパーミッションが必要です",
"xpack.security.management.roles.editRoleActionName": "{roleName} を編集",
"xpack.security.management.roles.fetchingRolesErrorMessage": "ロールの取得中にエラーが発生:{message}",
"xpack.security.management.roles.nameColumnName": "ロール",
"xpack.security.management.roles.noIndexPatternsPermission": "利用可能なインデックスパターンのリストへのアクセス権が必要です。",

View file

@ -20152,7 +20152,6 @@
"xpack.security.management.roleMappings.roleTemplates": "{templateCount, plural, other {# 个角色模板}}已定义",
"xpack.security.management.roleMappingsTitle": "角色映射",
"xpack.security.management.roles.actionsColumnName": "操作",
"xpack.security.management.roles.cloneRoleActionName": "克隆 {roleName}",
"xpack.security.management.roles.confirmDelete.cancelButtonLabel": "取消",
"xpack.security.management.roles.confirmDelete.deleteButtonLabel": "删除",
"xpack.security.management.roles.confirmDelete.removingRolesDescription": "您即将删除以下角色:",
@ -20163,7 +20162,6 @@
"xpack.security.management.roles.deleteSelectedRolesButtonLabel": "删除 {numSelected} 个角色{numSelected, plural, other {}}",
"xpack.security.management.roles.deletingRolesWarningMessage": "此操作无法撤消。",
"xpack.security.management.roles.deniedPermissionTitle": "您需要用于管理角色的权限",
"xpack.security.management.roles.editRoleActionName": "编辑 {roleName}",
"xpack.security.management.roles.fetchingRolesErrorMessage": "获取用户时出错:{message}",
"xpack.security.management.roles.nameColumnName": "角色",
"xpack.security.management.roles.noIndexPatternsPermission": "您需要访问可用索引模式列表的权限。",

View file

@ -81,7 +81,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
it('allows a role mapping to be deleted', async () => {
await testSubjects.click(`deleteRoleMappingButton-new_role_mapping`);
await testSubjects.click('euiCollapsedItemActionsButton');
await testSubjects.click('deleteRoleMappingButton-new_role_mapping');
await testSubjects.click('confirmModalConfirmButton');
await testSubjects.existOrFail('deletedRoleMappingSuccessToast');
});
@ -162,6 +163,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
}
});
it('allows a role mapping to be cloned', async () => {
await testSubjects.click('cloneRoleMappingButton-a_enabled_role_mapping');
await testSubjects.setValue('roleMappingFormNameInput', 'cloned_role_mapping');
await testSubjects.click('saveRoleMappingButton');
await testSubjects.existOrFail('savedRoleMappingSuccessToast');
const rows = await testSubjects.findAll('roleMappingRow');
expect(rows.length).to.eql(mappings.length + 1);
});
it('allows a role mapping to be edited', async () => {
await testSubjects.click('roleMappingName');
await testSubjects.click('saveRoleMappingButton');