[Security][Features] Adds subtext option to Kibana feature and sub-feature controls (#147709)

## Summary
- [x] Allow plugins to configure a description subtext underneath kibana
features
- [x] Allow plugins to configure a description subtext underneath kibana
subfeatures
- [x] Adjusts subfeature form UI so privilege buttons have fullwidth,
adjusts padding/margins
- [x] Adds unit tests

# Screen Shots
<img width="752" alt="image"
src="https://user-images.githubusercontent.com/56409205/211621510-83769516-4a04-4442-8d96-92f5b6708a45.png">


Privilege button group before

![image](https://user-images.githubusercontent.com/56409205/208610978-557d1881-f222-4a29-9ae3-d60baf34e1ac.png)

Privilege button group after
<img width="666" alt="image"
src="https://user-images.githubusercontent.com/56409205/211621622-36b7a388-f1f5-4cb4-810d-48adbf7f0155.png">


Example to test:
1. In `x-pack/plugins/security_solution/server/features.ts` before
`privilegeGroups` on line 254, add `description: 'some subfeature
description here'` and before `management` on line 551, add
`description: 'some feature description here'`.
3. Stack Management > Roles > edit Kibana Privileges > Security >
Security see descriptions show up underneath Security and underneath
Endpoint List sub feature
This commit is contained in:
Candace Park 2023-01-17 14:25:19 -08:00 committed by GitHub
parent 83a1904491
commit 28ba652c3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 257 additions and 31 deletions

View file

@ -32,6 +32,11 @@ export interface KibanaFeatureConfig {
*/
name: string;
/**
* An optional description that will appear as subtext underneath the feature name
*/
description?: string;
/**
* The category for this feature.
* This will be used to organize the list of features for display within the
@ -156,6 +161,10 @@ export class KibanaFeature {
return this.config.name;
}
public get description() {
return this.config.description;
}
public get order() {
return this.config.order;
}

View file

@ -29,6 +29,11 @@ export interface SubFeatureConfig {
/** Collection of privilege groups */
privilegeGroups: readonly SubFeaturePrivilegeGroupConfig[];
/**
* An optional description that will appear as subtext underneath the sub-feature name
*/
description?: string;
}
/**
@ -105,6 +110,10 @@ export class SubFeature {
return this.config.requireAllSpaces ?? false;
}
public get description() {
return this.config.description || '';
}
public toRaw() {
return { ...this.config };
}

View file

@ -165,6 +165,7 @@ const kibanaSubFeatureSchema = schema.object({
name: schema.string(),
requireAllSpaces: schema.maybe(schema.boolean()),
privilegesTooltip: schema.maybe(schema.string()),
description: schema.maybe(schema.string()),
privilegeGroups: schema.maybe(
schema.arrayOf(
schema.oneOf([
@ -198,6 +199,7 @@ const kibanaFeatureSchema = schema.object({
}),
name: schema.string(),
category: appCategorySchema,
description: schema.maybe(schema.string()),
order: schema.maybe(schema.number()),
excludeFromBasePrivileges: schema.maybe(schema.boolean()),
minimumLicense: schema.maybe(validLicenseSchema),

View file

@ -11,7 +11,7 @@ import { KibanaFeature } from '@kbn/features-plugin/public';
export const createFeature = (
config: Pick<
KibanaFeatureConfig,
'id' | 'name' | 'subFeatures' | 'reserved' | 'privilegesTooltip'
'id' | 'name' | 'subFeatures' | 'reserved' | 'privilegesTooltip' | 'description'
> & {
excludeFromBaseAll?: boolean;
excludeFromBaseRead?: boolean;

View file

@ -1,5 +1,11 @@
.subFeaturePrivilegeExpandedRegion {
background-color: $euiColorLightestShade;
padding-left: $euiSizeXXL;
padding-top: $euiSizeS;
}
.euiAccordionWithDescription:hover, .euiAccordionWithDescription:focus {
text-decoration: none;
}
.subFeaturePanel {
margin-left: $euiSizeL + $euiSizeXS;
}
.noSubFeaturePrivileges {
margin-left: $euiSizeL + $euiSizeXS;
}

View file

@ -837,6 +837,63 @@ describe('FeatureTable', () => {
expect(findTestSubject(wrapper, 'primaryFeaturePrivilegeControl')).toHaveLength(0);
});
it('renders subtext for features that define an optional description', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {
my_feature: ['all'],
},
},
]);
const featureWithDescription = createFeature({
id: 'my_feature',
name: 'Some Feature',
description: 'a description of my feature',
});
const { wrapper } = setup({
role,
features: [featureWithDescription],
privilegeIndex: 0,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges: false,
});
expect(findTestSubject(wrapper, 'featurePrivilegeDescriptionText').exists()).toEqual(true);
expect(
findTestSubject(wrapper, 'featurePrivilegeDescriptionText').text()
).toMatchInlineSnapshot(`"a description of my feature"`);
});
it('does not render subtext for features without a description', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {
my_feature: ['all'],
},
},
]);
const featureWithDescription = createFeature({
id: 'my_feature',
name: 'Some Feature',
});
const { wrapper } = setup({
role,
features: [featureWithDescription],
privilegeIndex: 0,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges: false,
});
expect(findTestSubject(wrapper, 'featurePrivilegeDescriptionText').exists()).toEqual(false);
});
it('renders renders the primary feature controls when both primary and reserved privileges are specified', () => {
const role = createRole([
{
@ -1315,4 +1372,100 @@ describe('FeatureTable', () => {
expect(type).toBe('empty');
});
});
describe('Optional description for sub-features', () => {
const role = createRole([
{
spaces: ['foo'],
base: [],
feature: {
unit_test: ['minimal_read', 'sub-toggle-1', 'sub-toggle-2'],
},
},
]);
it('renders description subtext if defined', () => {
const feature = createFeature({
id: 'unit_test',
name: 'Unit Test Feature',
subFeatures: [
{
name: 'Some Sub Feature',
description: 'some sub feature description',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub-toggle-1',
name: 'Sub Toggle 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-1'],
},
],
},
],
},
] as SubFeatureConfig[],
});
const { wrapper } = setup({
role,
features: [feature],
privilegeIndex: 0,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges: true,
});
const categoryExpander = findTestSubject(wrapper, 'featureCategoryButton_foo');
categoryExpander.simulate('click');
const featureExpander = findTestSubject(wrapper, 'featureTableCell');
featureExpander.simulate('click');
expect(findTestSubject(wrapper, 'subFeatureDescription').exists()).toEqual(true);
expect(findTestSubject(wrapper, 'subFeatureDescription').text()).toMatchInlineSnapshot(
`"some sub feature description"`
);
});
it('should not render description subtext if undefined', () => {
const feature = createFeature({
id: 'unit_test',
name: 'Unit Test Feature',
subFeatures: [
{
name: 'Some Sub Feature',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'sub-toggle-1',
name: 'Sub Toggle 1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: ['sub-toggle-1'],
},
],
},
],
},
] as SubFeatureConfig[],
});
const { wrapper } = setup({
role,
features: [feature],
privilegeIndex: 0,
calculateDisplayedPrivileges: false,
canCustomizeSubFeaturePrivileges: true,
});
const categoryExpander = findTestSubject(wrapper, 'featureCategoryButton_foo');
categoryExpander.simulate('click');
const featureExpander = findTestSubject(wrapper, 'featureTableCell');
featureExpander.simulate('click');
expect(findTestSubject(wrapper, 'subFeatureDescription').exists()).toEqual(false);
});
});
});

View file

@ -17,10 +17,12 @@ import {
EuiHorizontalRule,
EuiIcon,
EuiIconTip,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import classNames from 'classnames';
import type { ReactElement } from 'react';
import React, { Component } from 'react';
@ -221,11 +223,12 @@ export class FeatureTable extends Component<Props, State> {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>{infoIcon}</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem className="eui-fullWidth">
<EuiAccordion
id={`featurePrivilegeControls_${feature.id}`}
data-test-subj="featurePrivilegeControls"
buttonContent={buttonContent}
buttonClassName="euiAccordionWithDescription"
extraAction={extraAction}
forceState={hasSubFeaturePrivileges ? undefined : 'closed'}
arrowDisplay={hasSubFeaturePrivileges ? 'left' : 'none'}
@ -241,7 +244,8 @@ export class FeatureTable extends Component<Props, State> {
});
}}
>
<div className="subFeaturePrivilegeExpandedRegion">
<EuiSpacer size="s" />
<EuiPanel color="subdued" paddingSize="s" className="subFeaturePanel">
<FeatureTableExpandedRow
feature={feature}
privilegeIndex={this.props.privilegeIndex}
@ -256,7 +260,7 @@ export class FeatureTable extends Component<Props, State> {
this.props.canCustomizeSubFeaturePrivileges
}
/>
</div>
</EuiPanel>
</EuiAccordion>
</EuiFlexItem>
</EuiFlexGroup>
@ -267,9 +271,7 @@ export class FeatureTable extends Component<Props, State> {
if (feature.reserved && primaryFeaturePrivileges.length === 0) {
const buttonContent = (
<>
{<EuiIcon type="empty" size="l" />} <FeatureTableCell feature={feature} />
</>
<FeatureTableCell className="noSubFeaturePrivileges" feature={feature} />
);
const extraAction = (
@ -336,10 +338,10 @@ export class FeatureTable extends Component<Props, State> {
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
const buttonContent = (
<>
{!hasSubFeaturePrivileges && <EuiIcon type="empty" size="l" />}{' '}
<FeatureTableCell feature={feature} />
</>
<FeatureTableCell
className={classNames({ noSubFeaturePrivileges: !hasSubFeaturePrivileges })}
feature={feature}
/>
);
const extraAction = (

View file

@ -70,7 +70,7 @@ export const FeatureTableExpandedRow = ({
};
return (
<EuiFlexGroup direction="column">
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<div>
<EuiSwitch

View file

@ -64,15 +64,30 @@ export const SubFeatureForm = (props: Props) => {
if (groupsWithPrivileges.length === 0) {
return null;
}
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
{props.subFeature.name} {getTooltip()}
</EuiText>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={3}>
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem>
<EuiText size="s">
{props.subFeature.name} {getTooltip()}
</EuiText>
</EuiFlexItem>
{props.subFeature.description && (
<EuiFlexItem>
<EuiText
color={'subdued'}
size={'xs'}
data-test-subj="subFeatureDescription"
aria-describedby={`${props.subFeature.name} description text`}
>
{props.subFeature.description}
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>{groupsWithPrivileges.map(renderPrivilegeGroup)}</EuiFlexItem>
<EuiFlexItem grow={2}>{groupsWithPrivileges.map(renderPrivilegeGroup)}</EuiFlexItem>
</EuiFlexGroup>
);
@ -157,6 +172,7 @@ export const SubFeatureForm = (props: Props) => {
key={index}
buttonSize="compressed"
data-test-subj="mutexSubFeaturePrivilegeControl"
isFullWidth
options={options}
idSelected={firstSelectedPrivilege?.id ?? NO_PRIVILEGE_VALUE}
isDisabled={props.disabled}

View file

@ -0,0 +1,3 @@
.featurePrivilegeName:hover, .featurePrivilegeName:focus {
text-decoration: underline;
}

View file

@ -25,7 +25,7 @@ describe('FeatureTableCell', () => {
<FeatureTableCell feature={new SecuredFeature(feature.toRaw())} />
);
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`);
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature"`);
expect(wrapper.find(EuiIconTip)).toHaveLength(0);
});
@ -40,7 +40,7 @@ describe('FeatureTableCell', () => {
<FeatureTableCell feature={new SecuredFeature(feature.toRaw())} />
);
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature Info"`);
expect(wrapper.text()).toMatchInlineSnapshot(`"Test FeatureInfo"`);
expect(wrapper.find(EuiIconTip).props().content).toMatchInlineSnapshot(`
<EuiText>

View file

@ -5,16 +5,19 @@
* 2.0.
*/
import { EuiIconTip, EuiText } from '@elastic/eui';
import './feature_table_cell.scss';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui';
import React from 'react';
import type { SecuredFeature } from '../../../../model';
interface Props {
feature: SecuredFeature;
className?: string;
}
export const FeatureTableCell = ({ feature }: Props) => {
export const FeatureTableCell = ({ feature, className }: Props) => {
let tooltipElement = null;
if (feature.getPrivilegesTooltip()) {
const tooltipContent = (
@ -35,8 +38,27 @@ export const FeatureTableCell = ({ feature }: Props) => {
}
return (
<span data-test-subj={`featureTableCell`}>
{feature.name} {tooltipElement}
</span>
<EuiFlexGroup className={className} direction="column" gutterSize="none" component="span">
<EuiFlexItem data-test-subj={`featureTableCell`} component="span">
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem className="featurePrivilegeName" grow={false}>
{feature.name}
</EuiFlexItem>
<EuiFlexItem grow={false}>{tooltipElement}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{feature.description && (
<EuiFlexItem>
<EuiText
color="subdued"
size="xs"
data-test-subj="featurePrivilegeDescriptionText"
aria-describedby={`${feature.name} description text`}
>
{feature.description}
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -44,4 +44,8 @@ export class SecuredSubFeature extends SubFeature {
.filter((privilege) => predicate(privilege, this));
}
}
public getDescription() {
return this.description;
}
}