User Permissions - initial implementation (#1046)

* initial checkin of component permissions

* initial implementaiton of user permissions for devices, device groups, rules, jobs, and alarms

* review feedback

* fix nit in readme
This commit is contained in:
Mary Ellen Chaffin 2018-07-18 14:14:43 -07:00 коммит произвёл Stephen Pryor
Родитель 3a4c5bb933
Коммит 5f784514e0
25 изменённых файлов: 525 добавлений и 279 удалений

Просмотреть файл

@ -28,6 +28,9 @@
"retryFailure": "Oops, we got a temporary error from the service but were unable to recover. Try again later.",
"unknown": "An unknown error occurred. {{message}}"
},
"protected": {
"permissionDenied": "Permission {{permission}} is denied."
},
"settingsFlyout": {
"title": "System settings",
"version": "Version {{version}}",

Просмотреть файл

@ -3,7 +3,8 @@
import React from 'react';
import { IoTHubManagerService } from 'services';
import { Btn } from 'components/shared';
import { permissions } from 'services/models';
import { Btn, Protected } from 'components/shared';
import { svgs, LinkedComponent } from 'utilities';
import Flyout from 'components/shared/flyout';
import DeviceGroupForm from './views/deviceGroupForm';
@ -69,7 +70,9 @@ export class ManageDeviceGroups extends LinkedComponent {
this.state.addNewDeviceGroup || !!this.state.selectedDeviceGroup
? <DeviceGroupForm {...this.props} {...this.state} cancel={this.closeForm} />
: <div>
<Protected permission={permissions.createDeviceGroups}>
<Btn className="add-btn" svg={svgs.plus} onClick={this.toggleNewFilter}>{t('deviceGroupsFlyout.create')}</Btn>
</Protected>
{deviceGroups.length > 0 && <DeviceGroups {...this.props} onEditDeviceGroup={this.onEditDeviceGroup} />}
</div>
}

Просмотреть файл

@ -2,6 +2,7 @@
import React from 'react';
import { permissions } from 'services/models';
import { svgs, LinkedComponent, Validator } from 'utilities';
import {
AjaxError,
@ -10,7 +11,8 @@ import {
FormControl,
FormGroup,
FormLabel,
Indicator
Indicator,
Protected
} from 'components/shared';
import { ConfigService } from 'services';
import {
@ -266,21 +268,25 @@ class DeviceGroupForm extends LinkedComponent {
}
{ this.state.isPending && <Indicator pattern="bar" size="medium" />}
<BtnToolbar>
<Protected permission={permissions.updateDeviceGroups}>
<Btn
primary
disabled={!this.formIsValid() || conditionsHaveErrors || this.state.isPending}
type="submit">
{t('deviceGroupsFlyout.save')}
</Btn>
</Protected>
<Btn svg={svgs.cancelX} onClick={this.props.cancel}>{t('deviceGroupsFlyout.cancel')}</Btn>
{
// Don't show delete btn if it is a new group or the group is currently active
this.state.isEdit &&
<Protected permission={permissions.deleteDeviceGroups}>
<Btn svg={svgs.trash}
onClick={this.deleteDeviceGroup}
disabled={this.props.activeDeviceGroupId === this.state.id || this.state.isPending}>
{t('deviceGroupsFlyout.conditions.delete')}
</Btn>
</Protected>
}
</BtnToolbar>
{ this.state.error && <AjaxError t={t} error={this.state.error} /> }

Просмотреть файл

@ -1,10 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
import React, { Component } from 'react';
import { permissions } from 'services/models';
import { DevicesGrid } from './devicesGrid';
import { DeviceGroupDropdownContainer as DeviceGroupDropdown } from 'components/app/deviceGroupDropdown';
import { ManageDeviceGroupsBtnContainer as ManageDeviceGroupsBtn } from 'components/app/manageDeviceGroupsBtn';
import { AjaxError, Btn, RefreshBar, PageContent, ContextMenu, SearchInput } from 'components/shared';
import {
AjaxError,
Btn,
ContextMenu,
PageContent,
Protected,
RefreshBar,
SearchInput
} from 'components/shared';
import { DeviceNewContainer } from './flyouts/deviceNew';
import { SIMManagementContainer } from './flyouts/SIMManagement';
import { svgs } from 'utilities';
@ -64,9 +74,15 @@ export class Devices extends Component {
<DeviceGroupDropdown />
<SearchInput onChange={this.searchOnChange} placeholder={t('devices.searchPlaceholder')} />
{ this.state.contextBtns }
<Protected permission={permissions.updateSIMManagement}>
<Btn svg={svgs.simmanagement} onClick={this.openSIMManagement}>{t('devices.flyouts.SIMManagement.title')}</Btn>
</Protected>
<Protected permission={permissions.createDevices}>
<Btn svg={svgs.plus} onClick={this.openNewDeviceFlyout}>{t('devices.flyouts.new.contextMenuName')}</Btn>
</Protected>
<Protected permission={permissions.updateDeviceGroups}>
<ManageDeviceGroupsBtn />
</Protected>
</ContextMenu>,
<PageContent className="devices-container" key="page-content">
<RefreshBar refresh={fetchDevices} time={lastUpdated} isPending={isPending} t={t} />

Просмотреть файл

@ -1,7 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
import React, { Component } from 'react';
import { Btn, PcsGrid } from 'components/shared';
import { permissions } from 'services/models';
import { Btn, PcsGrid, Protected } from 'components/shared';
import { deviceColumnDefs, defaultDeviceGridProps } from './devicesGridConfig';
import { DeviceDeleteContainer } from '../flyouts/deviceDelete';
import { DeviceJobsContainer } from '../flyouts/deviceJobs';
@ -39,8 +40,12 @@ export class DevicesGrid extends Component {
];
this.contextBtns = [
<Btn key="jobs" svg={svgs.reconfigure} onClick={this.openFlyout('jobs')}>{props.t('devices.flyouts.jobs.title')}</Btn>,
<Protected permission={permissions.createJobs}>
<Btn key="jobs" svg={svgs.reconfigure} onClick={this.openFlyout('jobs')}>{props.t('devices.flyouts.jobs.title')}</Btn>
</Protected>,
<Protected permission={permissions.deleteDevices}>
<Btn key="delete" svg={svgs.trash} onClick={this.openFlyout('delete')}>{props.t('devices.flyouts.delete.title')}</Btn>
</Protected>
];
}

Просмотреть файл

@ -4,8 +4,9 @@ import React from 'react';
import { Trans } from 'react-i18next';
import { Link } from 'react-router-dom'
import { permissions } from 'services/models';
import { LinkedComponent } from 'utilities';
import { FormControl } from 'components/shared';
import { FormControl, Protected } from 'components/shared';
import Flyout from 'components/shared/flyout';
import './SIMManagement.css';
@ -47,6 +48,7 @@ export class SIMManagement extends LinkedComponent {
<Flyout.CloseBtn onClick={onClose} />
</Flyout.Header>
<Flyout.Content className="sim-management-container">
<Protected permission={permissions.updateSIMManagement}>
<div className="sim-management-selector">
<div className="sim-management-label-selector">{t(`devices.flyouts.SIMManagement.provider`)}</div>
<div className="sim-management-dropdown">
@ -74,6 +76,7 @@ export class SIMManagement extends LinkedComponent {
</Section.Content>
</Section.Container>
}
</Protected>
</Flyout.Content>
</Flyout.Container>
);

Просмотреть файл

@ -6,6 +6,7 @@ import update from 'immutability-helper';
import { IoTHubManagerService } from 'services';
import { svgs } from 'utilities';
import { permissions } from 'services/models';
import {
AjaxError,
Btn,
@ -16,6 +17,7 @@ import {
FlyoutCloseBtn,
FlyoutContent,
Indicator,
Protected,
SectionDesc,
SectionHeader,
SummaryBody,
@ -131,6 +133,7 @@ export class DeviceDelete extends Component {
<FlyoutCloseBtn onClick={onClose} />
</FlyoutHeader>
<FlyoutContent>
<Protected permission={permissions.deleteDevices}>
<form className="device-delete-container" onSubmit={this.deleteDevices}>
<div className="device-delete-header">{t('devices.flyouts.delete.header')}</div>
<div className="device-delete-descr">{t('devices.flyouts.delete.description')}</div>
@ -172,6 +175,7 @@ export class DeviceDelete extends Component {
</BtnToolbar>
}
</form>
</Protected>
</FlyoutContent>
</Flyout>
);

Просмотреть файл

@ -3,7 +3,7 @@
import React from 'react';
import { LinkedComponent } from 'utilities';
import { permissions } from 'services/models';
import {
Flyout,
FlyoutHeader,
@ -13,6 +13,7 @@ import {
ErrorMsg,
FormGroup,
FormLabel,
Protected,
Radio
} from 'components/shared';
import {
@ -64,6 +65,7 @@ export class DeviceJobs extends LinkedComponent {
<FlyoutCloseBtn onClick={onClose} />
</FlyoutHeader>
<FlyoutContent>
<Protected permission={permissions.createJobs}>
<div className="device-jobs-container">
{
devices.length === 0 &&
@ -95,6 +97,7 @@ export class DeviceJobs extends LinkedComponent {
]
}
</div>
</Protected>
</FlyoutContent>
</Flyout>
);

Просмотреть файл

@ -4,7 +4,7 @@ import React from 'react';
import update from 'immutability-helper';
import { DeviceSimulationService, IoTHubManagerService } from 'services';
import { authenticationTypeOptions, toNewDeviceRequestModel } from 'services/models';
import { authenticationTypeOptions, permissions, toNewDeviceRequestModel } from 'services/models';
import {
copyToClipboard,
int,
@ -28,6 +28,7 @@ import {
FormLabel,
FormSection,
Indicator,
Protected,
Radio,
SectionDesc,
SectionHeader,
@ -330,6 +331,7 @@ export class DeviceNew extends LinkedComponent {
<FlyoutCloseBtn onClick={onClose} />
</FlyoutHeader>
<FlyoutContent>
<Protected permission={permissions.createDevices}>
<form className="devices-new-container" onSubmit={this.apply}>
<div className="devices-new-content">
<FormGroup>
@ -431,6 +433,7 @@ export class DeviceNew extends LinkedComponent {
]
}
</form>
</Protected>
</FlyoutContent>
</Flyout>
);

Просмотреть файл

@ -5,8 +5,17 @@ import { Trans } from 'react-i18next';
import { Observable, Subject } from 'rxjs';
import Config from 'app.config';
import { permissions } from 'services/models';
import { RulesGrid } from 'components/pages/rules/rulesGrid';
import { AjaxError, Btn, PageContent, ContextMenu, RefreshBar, Indicator } from 'components/shared';
import {
AjaxError,
Btn,
ContextMenu,
Indicator,
PageContent,
Protected,
RefreshBar
} from 'components/shared';
import { svgs, joinClasses, renderUndefined } from 'utilities';
import { DevicesGrid } from 'components/pages/devices/devicesGrid';
import { DeviceGroupDropdownContainer as DeviceGroupDropdown } from 'components/app/deviceGroupDropdown';
@ -194,15 +203,21 @@ export class RuleDetails extends Component {
const alertContextBtns =
selectedRows.length > 0
? [
<Protected permission={permissions.updateAlarms}>
<Btn svg={svgs.closeAlert} onClick={this.closeAlerts} key="close">
<Trans i18nKey="maintenance.close">Close</Trans>
</Btn>,
</Btn>
</Protected>,
<Protected permission={permissions.updateAlarms}>
<Btn svg={svgs.ackAlert} onClick={this.ackAlerts} key="ack">
<Trans i18nKey="maintenance.acknowledge">Acknowledge</Trans>
</Btn>,
</Btn>
</Protected>,
<Protected permission={permissions.deleteAlarms}>
<Btn svg={svgs.trash} onClick={this.deleteAlerts} key="delete">
<Trans i18nKey="maintenance.delete">Delete</Trans>
</Btn>
</Protected>
]
: null;
this.setState({

Просмотреть файл

@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
import React from 'react';
import { permissions } from 'services/models';
import { Protected, ProtectedError } from 'components/shared';
import { RuleEditorContainer } from './ruleEditor';
import Flyout from 'components/shared/flyout';
@ -11,7 +13,17 @@ export const EditRuleFlyout = ({ t, onClose, rule }) => (
<Flyout.CloseBtn onClick={onClose} />
</Flyout.Header>
<Flyout.Content>
<RuleEditorContainer onClose={onClose} rule={rule} />
<Protected permission={permissions.updateRules}>{
(hasPermission, permission) =>
hasPermission
? <RuleEditorContainer onClose={onClose} rule={rule} />
:
<div>
<ProtectedError t={t} permission={permission} />
<p>A read-only view will be added soon as part of another PBI.</p>
</div>
}
</Protected>
</Flyout.Content>
</Flyout.Container>
);

Просмотреть файл

@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
import React from 'react';
import { permissions } from 'services/models';
import { Protected } from 'components/shared';
import { RuleEditorContainer } from './ruleEditor';
import Flyout from 'components/shared/flyout';
@ -11,7 +13,9 @@ export const NewRuleFlyout = ({ t, onClose }) => (
<Flyout.CloseBtn onClick={onClose} />
</Flyout.Header>
<Flyout.Content>
<Protected permission={permissions.createRules}>
<RuleEditorContainer onClose={onClose} />
</Protected>
</Flyout.Content>
</Flyout.Container>
);

Просмотреть файл

@ -4,10 +4,12 @@ import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { Trans } from 'react-i18next';
import { permissions } from 'services/models';
import {
AjaxError,
Btn,
BtnToolbar,
Protected,
Svg
} from 'components/shared';
import { svgs } from 'utilities';
@ -112,6 +114,7 @@ export class DeleteRule extends Component {
<Flyout.CloseBtn onClick={onClose} />
</Flyout.Header>
<Flyout.Content>
<Protected permission={permissions.deleteRules}>
<form onSubmit={this.deleteRule} className="delete-rule-flyout-container">
{rule && <RuleSummary rule={rule} isPending={isPending} completedSuccessfully={completedSuccessfully} t={t} className="rule-details"/>}
{error && <AjaxError className="rule-delete-error" t={t} error={error} />}
@ -121,6 +124,7 @@ export class DeleteRule extends Component {
: this.renderButtons())
}
</form>
</Protected>
</Flyout.Content>
</Flyout.Container>
);

Просмотреть файл

@ -7,11 +7,12 @@ import {
AjaxError,
Btn,
BtnToolbar,
Protected,
ToggleBtn
} from 'components/shared';
import { svgs } from 'utilities';
import { TelemetryService } from 'services';
import { toNewRuleRequestModel } from 'services/models';
import { permissions, toNewRuleRequestModel } from 'services/models';
import Flyout from 'components/shared/flyout';
import './ruleStatus.css';
@ -84,6 +85,7 @@ export class RuleStatus extends Component {
<Flyout.CloseBtn onClick={onClose} />
</Flyout.Header>
<Flyout.Content>
<Protected permission={permissions.updateRules}>
<form onSubmit={this.changeRuleStatus} className="disable-rule-flyout-container">
<div className="padded-top-bottom">
<ToggleBtn
@ -106,6 +108,7 @@ export class RuleStatus extends Component {
</BtnToolbar>
}
</form>
</Protected>
</Flyout.Content>
</Flyout.Container>
);

Просмотреть файл

@ -1,10 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
import React, { Component } from 'react';
import { permissions } from 'services/models';
import { RulesGrid } from './rulesGrid';
import { DeviceGroupDropdownContainer as DeviceGroupDropdown } from 'components/app/deviceGroupDropdown';
import { ManageDeviceGroupsBtnContainer as ManageDeviceGroupsBtn } from 'components/app/manageDeviceGroupsBtn';
import { AjaxError, Btn, RefreshBar, PageContent, ContextMenu, SearchInput } from 'components/shared';
import {
AjaxError,
Btn,
ContextMenu,
PageContent,
Protected,
RefreshBar,
SearchInput
} from 'components/shared';
import { NewRuleFlyout } from './flyouts';
import { svgs } from 'utilities';
@ -77,7 +86,9 @@ export class Rules extends Component {
<DeviceGroupDropdown />
<SearchInput onChange={this.searchOnChange} placeholder={t('rules.searchPlaceholder')} />
{this.state.contextBtns}
<Btn svg={svgs.plus} onClick={this.openNewRuleFlyout}>New rule</Btn>
<Protected permission={permissions.createRules}>
<Btn svg={svgs.plus} onClick={this.openNewRuleFlyout}>{t('rules.flyouts.newRule')}</Btn>
</Protected>
<ManageDeviceGroupsBtn />
</ContextMenu>,
<PageContent className="rules-container" key="page-content">

Просмотреть файл

@ -3,7 +3,8 @@
import React, { Component } from 'react';
import { Trans } from 'react-i18next';
import { Btn, PcsGrid } from 'components/shared';
import { permissions } from 'services/models';
import { Btn, PcsGrid, Protected } from 'components/shared';
import { rulesColumnDefs, defaultRulesGridProps } from './rulesGridConfig';
import { checkboxColumn } from 'components/shared/pcsGrid/pcsGridConfig';
import { isFunc, translateColumnDefs, svgs } from 'utilities';
@ -41,25 +42,35 @@ export class RulesGrid extends Component {
this.contextBtns = {
disable:
<Protected permission={permissions.updateRules}>
<Btn key="disable" className="rule-status-btn" svg={svgs.disableToggle} onClick={this.openStatusFlyout}>
<Trans i18nKey="rules.flyouts.disable">Disable</Trans>
</Btn>,
</Btn>
</Protected>,
enable:
<Protected permission={permissions.updateRules}>
<Btn key="enable" className="rule-status-btn enabled" svg={svgs.enableToggle} onClick={this.openStatusFlyout}>
<Trans i18nKey="rules.flyouts.enable">Enable</Trans>
</Btn>,
</Btn>
</Protected>,
changeStatus:
<Protected permission={permissions.updateRules}>
<Btn key="changeStatus" className="rule-status-btn" svg={svgs.changeStatus} onClick={this.openStatusFlyout}>
<Trans i18nKey="rules.flyouts.changeStatus">Change status</Trans>
</Btn>,
</Btn>
</Protected>,
edit:
<Protected permission={permissions.updateRules}>
<Btn key="edit" svg={svgs.edit} onClick={this.openEditRuleFlyout}>
{props.t('rules.flyouts.edit')}
</Btn>,
</Btn>
</Protected>,
delete:
<Protected permission={permissions.deleteRules}>
<Btn key="delete" svg={svgs.trash} onClick={this.openDeleteFlyout}>
<Trans i18nKey="rules.flyouts.delete">Delete</Trans>
</Btn>
</Protected>
};
}

Просмотреть файл

@ -9,5 +9,6 @@ export * from './forms';
export * from './indicator/indicator';
export * from './pageContent/pageContent';
export * from './pcsGrid/pcsGrid';
export * from './protected'
export * from './refreshBar/refreshBar';
export * from './svg/svg';

Просмотреть файл

@ -0,0 +1,46 @@
Protected Components
=================================
These components are intended to enforce user permissions in the UI layer.
The individual permissions are defined with the [models for the auth microservice](../../../services/models/authModels.js).
## Using the Protected component
React components allow you to pass functions as children instead of pure JSX. So the Protected component can have two use cases
enabling it to be simple, declarative, highly customizable, and hide the permission logic from the rest of the app.
### Use case 1: 
Pass pure JSX as the children will either render or not render the children based on the permission status.
<Protected permissions={permissions.deleteDevices}>
  <Btn>Protected Button</Btn>
</Protected>
 
### Use case 2: 
Pass a function as the children. In this case, the Protected component will pass a boolean indicating if the user has the required permission or not.
<Protected permissions={permissions.deleteDevices}>
  {
    (hasPermission) => hasPermission 
      ? <FormControl link={this.userEmails}> 
      : <span>You don't have permission to edit user emails :(</span>
  }
</Protected>
Additionally, a parameter containing the permission will also be passed. See below for an example of its use with the ProtectedError component.
## Using the optional ProtectedError component
If you'd like to show a default message instead of simply hiding the children, use case 2 above along with the ProtectedError component can assist.
<Protected permissions={permissions.deleteDevices}>
  {
    (hasPermission, permission) => hasPermission 
      ? <FormControl link={this.userEmails}> 
      : <ProtectedError t={t} permission={permission} /> 
  }
</Protected>

Просмотреть файл

@ -0,0 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './protected.container';
export * from './protectedError';

Просмотреть файл

@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
import { connect } from 'react-redux';
import { getUserPermissions } from 'store/reducers/appReducer';
import { ProtectedImpl } from './protected.impl';
const mapStateToProps = state => ({
userPermissions: getUserPermissions(state)
});
export const Protected = connect(mapStateToProps, null)(ProtectedImpl);

Просмотреть файл

@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
import { Component } from 'react';
import { isFunc } from 'utilities';
export class ProtectedImpl extends Component {
userHasPermission() {
const { permission, userPermissions } = this.props;
return userPermissions.has(permission);
}
render() {
const { children, permission } = this.props;
const hasPermission = this.userHasPermission();
if (isFunc(children)) {
return children(hasPermission, permission);
}
return hasPermission ? children : null;
}
};

Просмотреть файл

@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
import React from 'react';
import { ErrorMsg } from '../forms';
import { joinClasses } from 'utilities';
export const ProtectedError = ({ permission, t, className }) => (
<ErrorMsg className={joinClasses('protected-error', className)}>{t('protected.permissionDenied', { permission })}</ErrorMsg>
);

Просмотреть файл

@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
export const permissions = {
createDeviceGroups: 'CreateDeviceGroups',
deleteDeviceGroups: 'DeleteDeviceGroups',
updateDeviceGroups: 'UpdateDeviceGroups',
createDevices: 'CreateDevices',
deleteDevices: 'DeleteDevices',
updateDevices: 'UpdateDevices',
createRules: 'CreateRules',
deleteRules: 'DeleteRules',
updateRules: 'UpdateRules',
deleteAlarms: 'DeleteAlarms',
updateAlarms: 'UpdateAlarms',
createJobs: 'CreateJobs',
updateSIMManagement: 'UpdateSIMManagement'
};

Просмотреть файл

@ -3,6 +3,7 @@
// Exports models
export * from './ajaxModels';
export * from './authModels';
export * from './deviceSimulationModels';
export * from './iotHubManagerModels';
export * from './telemetryModels';

Просмотреть файл

@ -6,6 +6,7 @@ import { ConfigService, GitHubService } from 'services';
import { schema, normalize } from 'normalizr';
import { createSelector } from 'reselect';
import update from 'immutability-helper';
import { permissions } from 'services/models';
import {
createAction,
createReducerScenario,
@ -121,7 +122,29 @@ const initialState = {
isDefaultLogo: true,
azureMapsKey: '',
deviceGroupFlyoutIsOpen: false,
timeInterval: 'PT1H'
timeInterval: 'PT1H',
//TODO: Get this from the server. This is just hardcoded test data for now.
userPermissions: new Set([
permissions.createDeviceGroups,
permissions.deleteDeviceGroups,
permissions.updateDeviceGroups,
permissions.createDevices,
permissions.deleteDevices,
permissions.updateDevices,
permissions.createRules,
permissions.deleteRules,
permissions.updateRules,
permissions.deleteAlarms,
permissions.updateAlarms,
permissions.createJobs,
permissions.updateSIMManagement
])
};
const updateDeviceGroupsReducer = (state, { payload, fromAction }) => {
@ -247,4 +270,6 @@ export const getLogoPendingStatus = state =>
getPending(getAppReducer(state), epics.actionTypes.fetchLogo);
export const getTimeInterval = state => getAppReducer(state).timeInterval;
export const getUserPermissions = state => getAppReducer(state).userPermissions;
// ========================= Selectors - END