Roles and Permissions UI (#333)

* feat(ui) - roles with projectId and ui improvements

* feat(ui) - roles fixes

* feat(ui) - roles fixes

* feat(ui) - roles menu item icon change
This commit is contained in:
Shekar Siri 2022-02-16 17:00:48 +01:00 committed by GitHub
parent b90d2a25f9
commit 4eaee22d30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 254 additions and 96 deletions

View file

@ -102,7 +102,7 @@ class Router extends React.Component {
this.props.fetchTenants();
}
if (!prevProps.isLoggedIn && this.props.isLoggedIn && this.state.destinationPath !== routes.login() && this.state.destinationPath !== '/') {
if (this.state.destinationPath && !prevProps.isLoggedIn && this.props.isLoggedIn && this.state.destinationPath !== routes.login() && this.state.destinationPath !== '/') {
this.props.history.push(this.state.destinationPath);
this.setState({ destinationPath: null });
}

View file

@ -33,7 +33,7 @@ const LIMIT_WARNING = 'You have reached users limit.';
generateInviteLink,
fetchRoles
})
@withPageTitle('Users - OpenReplay Preferences')
@withPageTitle('Team - OpenReplay Preferences')
class ManageUsers extends React.PureComponent {
state = { showModal: false, remaining: this.props.account.limits.teamMember.remaining, invited: false }

View file

@ -5,6 +5,7 @@ import { Checkbox } from 'UI'
import { connect } from 'react-redux'
import { withRequest } from 'HOCs'
import { fetch as fetchConfig, edit as editConfig, save as saveConfig } from 'Duck/config'
import withPageTitle from 'HOCs/withPageTitle';
function Notifications(props) {
const { config } = props;
@ -42,4 +43,4 @@ function Notifications(props) {
export default connect(state => ({
config: state.getIn(['config', 'options'])
}), { fetchConfig, editConfig, saveConfig })(Notifications)
}), { fetchConfig, editConfig, saveConfig })(withPageTitle('Notifications - OpenReplay Preferences')(Notifications));

View file

@ -68,25 +68,25 @@ function PreferencesMenu({ activeTab, appearance, history, isEnterprise }) {
/>
</div>
<div className="mb-4">
<SideMenuitem
active={ activeTab === CLIENT_TABS.MANAGE_USERS }
title="Users"
iconName="users"
onClick={() => setTab(CLIENT_TABS.MANAGE_USERS) }
/>
</div>
{ isEnterprise && (
<div className="mb-4">
<SideMenuitem
active={ activeTab === CLIENT_TABS.MANAGE_ROLES }
title="Roles"
iconName="shield-lock"
title="Roles & Access"
iconName="diagram-3"
onClick={() => setTab(CLIENT_TABS.MANAGE_ROLES) }
/>
</div>
)}
<div className="mb-4">
<SideMenuitem
active={ activeTab === CLIENT_TABS.MANAGE_USERS }
title="Team"
iconName="users"
onClick={() => setTab(CLIENT_TABS.MANAGE_USERS) }
/>
</div>
<div className="mb-4">
<SideMenuitem

View file

@ -8,6 +8,7 @@ import { init, edit, fetchList, remove as deleteRole, resetErrors } from 'Duck/r
import RoleItem from './components/RoleItem'
import { confirm } from 'UI/Confirmation';
import { toast } from 'react-toastify';
import withPageTitle from 'HOCs/withPageTitle';
interface Props {
loading: boolean
@ -20,11 +21,12 @@ interface Props {
account: any,
permissionsMap: any,
removeErrors: any,
resetErrors: () => void
resetErrors: () => void,
projectsMap: any,
}
function Roles(props: Props) {
const { loading, instance, roles, init, edit, deleteRole, account, permissionsMap, removeErrors } = props
const { loading, instance, roles, init, edit, deleteRole, account, permissionsMap, projectsMap, removeErrors } = props
const [showModal, setShowmModal] = useState(false)
const isAdmin = account.admin || account.superAdmin;
@ -72,16 +74,16 @@ function Roles(props: Props) {
<React.Fragment>
<Loader loading={ loading }>
<SlideModal
title={ instance.exists() ? "Edit Role" : "Add Role" }
title={ instance.exists() ? "Edit Role" : "Create Role" }
size="small"
isDisplayed={showModal }
content={ showModal && <RoleForm closeModal={closeModal}/> }
content={ showModal && <RoleForm closeModal={closeModal} permissionsMap={permissionsMap} deleteHandler={deleteHandler} /> }
onClose={ closeModal }
/>
<div className={ stl.wrapper }>
<div className={ cn(stl.tabHeader, 'flex items-center') }>
<div className="flex items-center mr-auto">
<h3 className={ cn(stl.tabTitle, "text-2xl") }>Manage Roles and Permissions</h3>
<h3 className={ cn(stl.tabTitle, "text-2xl") }>Roles and Access</h3>
<Popup
trigger={
<div>
@ -111,11 +113,17 @@ function Roles(props: Props) {
icon
>
<div className={''}>
<div className={cn(stl.wrapper, 'flex items-start py-3 border-b px-3 pr-20')}>
<div className="flex" style={{ width: '20%'}}>Title</div>
<div className="flex" style={{ width: '30%'}}>Project Access</div>
<div className="flex" style={{ width: '50%'}}>Feature Access</div>
</div>
{roles.map(role => (
<RoleItem
role={role}
isAdmin={isAdmin}
permissions={permissionsMap}
projects={projectsMap}
editHandler={editHandler}
deleteHandler={deleteHandler}
/>
@ -132,14 +140,20 @@ export default connect(state => {
const permissions = state.getIn(['roles', 'permissions'])
const permissionsMap = {}
permissions.forEach(p => {
permissionsMap[p.value] = p.name
permissionsMap[p.value] = p.text
});
const projects = state.getIn([ 'site', 'list' ])
return {
instance: state.getIn(['roles', 'instance']) || null,
permissionsMap: permissionsMap,
roles: state.getIn(['roles', 'list']),
removeErrors: state.getIn(['roles', 'removeRequest', 'errors']),
loading: state.getIn(['roles', 'fetchRequest', 'loading']),
account: state.getIn([ 'user', 'account' ])
account: state.getIn([ 'user', 'account' ]),
projectsMap: projects.reduce((acc, p) => {
acc[ p.get('id') ] = p.get('name')
return acc
}
, {}),
}
}, { init, edit, fetchList, deleteRole, resetErrors })(Roles)
}, { init, edit, fetchList, deleteRole, resetErrors })(withPageTitle('Roles & Access - OpenReplay Preferences')(Roles))

View file

@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react'
import { connect } from 'react-redux'
import stl from './roleForm.css'
import { save, edit } from 'Duck/roles'
import { Input, Button, Checkbox } from 'UI'
import { Input, Button, Checkbox, Dropdown, Icon } from 'UI'
interface Permission {
name: string,
@ -16,9 +16,14 @@ interface Props {
closeModal: (toastMessage?: string) => void,
saving: boolean,
permissions: Array<Permission>[]
projectOptions: Array<any>[],
permissionsMap: any,
projectsMap: any,
deleteHandler: (id: any) => Promise<void>,
}
const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props) => {
const RoleForm = (props: Props) => {
const { role, edit, save, closeModal, saving, permissions, projectOptions, permissionsMap, projectsMap } = props
let focusElement = useRef<any>(null)
const _save = () => {
save(role).then(() => {
@ -28,13 +33,33 @@ const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props)
const write = ({ target: { value, name } }) => edit({ [ name ]: value })
const onChangeOption = (e) => {
const onChangePermissions = (e) => {
const { permissions } = role
const index = permissions.indexOf(e)
const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e)
edit({ permissions: _perms })
}
const onChangeProjects = (e) => {
const { projects } = role
const index = projects.indexOf(e)
const _projects = index === -1 ? projects.push(e) : projects.remove(index)
edit({ projects: _projects })
}
const writeOption = (e, { name, value }) => {
if (name === 'permissions') {
onChangePermissions(value)
} else if (name === 'projects') {
onChangeProjects(value)
}
}
const toggleAllProjects = () => {
const { allProjects } = role
edit({ allProjects: !allProjects })
}
useEffect(() => {
focusElement && focusElement.current && focusElement.current.focus()
}, [])
@ -42,8 +67,8 @@ const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props)
return (
<div className={ stl.form }>
<form onSubmit={ _save } >
<div className={ stl.formGroup }>
<label>{ 'Name' }</label>
<div className="form-group">
<label>{ 'Title' }</label>
<Input
ref={ focusElement }
name="name"
@ -51,22 +76,77 @@ const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props)
onChange={ write }
className={ stl.input }
id="name-field"
placeholder="Ex. Admin"
/>
</div>
<div>
{ permissions.map((permission: any, index) => (
<div key={ index } className={ stl.formGroup }>
<Checkbox
name="permissions"
className="font-medium"
type="checkbox"
checked={ role.permissions.contains(permission.value) }
onClick={ () => onChangeOption(permission.value) }
label={permission.name}
/>
<div className="form-group flex flex-col">
<label>{ 'Project Access' }</label>
<div className="flex my-3">
<Checkbox
name="allProjects"
className="font-medium"
type="checkbox"
checked={ role.allProjects }
onClick={toggleAllProjects}
label={''}
/>
<div className="cursor-pointer" onClick={toggleAllProjects}>
<div>All Projects</div>
<span className="text-xs text-gray-600">
(Uncheck to select specific projects)
</span>
</div>
))}
</div>
{ !role.allProjects && (
<>
<Dropdown
search
className="fluid"
placeholder="Select"
selection
options={ projectOptions }
name="projects"
value={null}
onChange={ writeOption }
id="change-dropdown"
selectOnBlur={false}
selectOnNavigation={false}
/>
{ role.projects.size > 0 && (
<div className="flex flex-row items-start flex-wrap mt-4">
{ role.projects.map(p => (
OptionLabel(projectsMap, p, onChangeProjects)
)) }
</div>
)}
</>
)}
</div>
<div className="form-group flex flex-col">
<label>{ 'Capability Access' }</label>
<Dropdown
search
className="fluid"
placeholder="Select"
selection
options={ permissions }
name="permissions"
value={null}
onChange={ writeOption }
id="change-dropdown"
selectOnBlur={false}
selectOnNavigation={false}
/>
{ role.permissions.size > 0 && (
<div className="flex flex-row items-start flex-wrap mt-4">
{ role.permissions.map(p => (
OptionLabel(permissionsMap, p, onChangePermissions)
)) }
</div>
)}
</div>
</form>
@ -89,13 +169,50 @@ const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props)
{ 'Cancel' }
</Button>
</div>
{ role.exists() && (
<div>
<Button
data-hidden={ !role.exists() }
onClick={ () => props.deleteHandler(role) }
hover
noPadding
>
<Icon name="trash" size="18"/>
</Button>
</div>
)}
</div>
</div>
);
}
export default connect(state => ({
role: state.getIn(['roles', 'instance']),
permissions: state.getIn(['roles', 'permissions']),
saving: state.getIn([ 'roles', 'saveRequest', 'loading' ]),
}), { edit, save })(RoleForm);
export default connect(state => {
const role = state.getIn(['roles', 'instance'])
const projects = state.getIn([ 'site', 'list' ])
return {
role,
projectOptions: projects.map(p => ({
key: p.get('id'),
value: p.get('id'),
text: p.get('name'),
disabled: role.projects.includes(p.get('id')),
})).toJS(),
permissions: state.getIn(['roles', 'permissions'])
.map(({ text, value }) => ({ text, value, disabled: role.permissions.includes(value) })).toJS(),
saving: state.getIn([ 'roles', 'saveRequest', 'loading' ]),
projectsMap: projects.reduce((acc, p) => {
acc[ p.get('id') ] = p.get('name')
return acc
}
, {}),
}
}, { edit, save })(RoleForm);
function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) {
return <div className="px-2 py-1 rounded bg-gray-lightest mr-2 mb-2 border flex items-center justify-between">
<div>{nameMap[p]}</div>
<div className="cursor-pointer ml-2" onClick={() => onChangeOption(p)}>
<Icon name="close" size="12" />
</div>
</div>
}

View file

@ -1,9 +1,5 @@
.form {
padding: 0 20px;
& .formGroup {
margin-bottom: 15px;
}
& label {
display: block;
margin-bottom: 5px;

View file

@ -1,11 +1,19 @@
import React from 'react'
import { Icon } from 'UI'
import { Icon, Link } from 'UI'
import stl from './roleItem.css'
import cn from 'classnames'
import { CLIENT_TABS, client as clientRoute } from 'App/routes';
function PermisionLabel({ permission }: any) {
function PermisionLabel({ label }: any) {
return (
<div className={cn(stl.label)}>{ permission }</div>
<div className={cn(stl.label, 'mb-2')}>{ label }</div>
);
}
function PermisionLabelLinked({ label, route }: any) {
return (
<Link to={route}><div className={cn(stl.label, 'mb-2 bg-active-blue color-teal')}>{ label }</div></Link>
);
}
@ -14,28 +22,33 @@ interface Props {
deleteHandler?: (role: any) => void,
editHandler?: (role: any) => void,
permissions: any,
isAdmin: boolean
isAdmin: boolean,
projects: any,
}
function RoleItem({ role, deleteHandler, editHandler, isAdmin, permissions }: Props) {
function RoleItem({ role, deleteHandler, editHandler, isAdmin, permissions, projects }: Props) {
return (
<div className={cn(stl.wrapper)}>
<Icon name="user-alt" size="16" marginRight="10" />
<div className="flex items-center">
<div className="mr-4">{ role.name }</div>
<div className="grid grid-flow-col auto-cols-max">
{role.permissions.map((permission: any) => (
<PermisionLabel permission={permissions[permission]} key={permission.id} />
// <span key={permission.id} className={cn(stl.permission)}>{ permissions[permission].name }</span>
))}
</div>
<div className={cn(stl.wrapper, 'flex items-start relative py-4 hover border-b px-3 pr-20')}>
<div className="flex" style={{ width: '20%'}}>
<Icon name="user-alt" size="16" marginRight="10" />
{ role.name }
</div>
<div className="flex items-start flex-wrap" style={{ width: '30%'}}>
{role.allProjects ? (
<PermisionLabelLinked label="All projects" route={clientRoute(CLIENT_TABS.SITES)}/>
) : (
role.projects.map(p => (
<PermisionLabel label={projects[p]} />
))
)}
</div>
<div className="flex items-start flex-wrap" style={{ width: '50%'}}>
{role.permissions.map((permission: any) => (
<PermisionLabel label={permissions[permission]} key={permission.id} />
))}
</div>
{ isAdmin && (
<div className={ stl.actions }>
{ !!deleteHandler &&
<div className={ cn(stl.button, {[stl.disabled] : role.protected }) } onClick={ () => deleteHandler(role) } id="trash">
<Icon name="trash" size="16" color="teal"/>
</div>
}
<div className={ cn(stl.actions, 'absolute right-0 top-0 bottom-0 mr-8') }>
{ !!editHandler &&
<div className={ cn(stl.button, {[stl.disabled] : role.protected }) } onClick={ () => editHandler(role) }>
<Icon name="edit" size="16" color="teal"/>
@ -43,7 +56,6 @@ function RoleItem({ role, deleteHandler, editHandler, isAdmin, permissions }: Pr
}
</div>
)}
</div>
);
}

View file

@ -1,13 +1,5 @@
.wrapper {
display: flex;
align-items: center;
width: 100%;
border-bottom: solid thin #e6e6e6;
padding: 10px 0px;
}
.actions {
margin-left: auto;
/* margin-left: auto; */
/* opacity: 0; */
transition: all 0.4s;
display: flex;
@ -37,11 +29,11 @@
}
.label {
margin-left: 10px;
margin-right: 10px;
padding: 0 5px;
border-radius: 3px;
background-color: $gray-lightest;
font-size: 10px;
font-size: 12px;
border: solid thin $gray-light;
width: fit-content;
}

View file

@ -37,7 +37,7 @@ const GDPR_FORM = 'GDPR_FORM';
remove,
fetchGDPR
})
@withPageTitle('Sites - OpenReplay Preferences')
@withPageTitle('Projects - OpenReplay Preferences')
class Sites extends React.PureComponent {
state = {
showTrackingCode: false,

View file

@ -9,6 +9,7 @@ import { fetchList as fetchSlackList } from 'Duck/integrations/slack';
import { errors as errorsRoute, isRoute } from "App/routes";
import EventFilter from 'Components/BugFinder/EventFilter';
import DateRange from 'Components/BugFinder/DateRange';
import withPageTitle from 'HOCs/withPageTitle';
import { SavedSearchList } from 'UI';
@ -43,6 +44,7 @@ function getStatusLabel(status) {
applyFilter,
fetchSlackList,
})
@withPageTitle("Errors - OpenReplay")
export default class Errors extends React.PureComponent {
state = {
status: UNRESOLVED,

View file

@ -2,6 +2,7 @@ import { List, Map } from 'immutable';
import Role from 'Types/role';
import crudDuckGenerator from './tools/crudDuck';
import { reduceDucks } from 'Duck/tools';
import { array, request, success, failure, createListUpdater, mergeReducers } from './funcTools/tools';
const crudDuck = crudDuckGenerator('client/role', Role, { idKey: 'roleId' });
export const { fetchList, init, edit, remove, } = crudDuck.actions;
@ -11,19 +12,29 @@ const RESET_ERRORS = 'roles/RESET_ERRORS';
const initialState = Map({
list: List(),
permissions: List([
{ name: 'Session Replay', value: 'SESSION_REPLAY' },
{ name: 'Developer Tools', value: 'DEV_TOOLS' },
{ name: 'Errors', value: 'ERRORS' },
{ name: 'Metrics', value: 'METRICS' },
{ name: 'Assist (Live)', value: 'ASSIST_LIVE' },
{ name: 'Assist (Call)', value: 'ASSIST_CALL' },
{ text: 'Session Replay', value: 'SESSION_REPLAY' },
{ text: 'Developer Tools', value: 'DEV_TOOLS' },
{ text: 'Errors', value: 'ERRORS' },
{ text: 'Metrics', value: 'METRICS' },
{ text: 'Assist (Live)', value: 'ASSIST_LIVE' },
{ text: 'Assist (Call)', value: 'ASSIST_CALL' },
])
});
const name = "role";
const idKey = "roleId";
const updateItemInList = createListUpdater(idKey);
const updateInstance = (state, instance) => state.getIn([ "instance", idKey ]) === instance[ idKey ]
? state.mergeIn([ "instance" ], instance)
: state;
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case RESET_ERRORS:
return state.setIn(['removeRequest', 'errors'], null);
case crudDuck.actionTypes.SAVE.SUCCESS:
return updateItemInList(updateInstance(state, action.data), action.data);
}
return state;
};

View file

@ -59,8 +59,8 @@ export const forgotPassword = () => '/reset-password';
export const CLIENT_TABS = {
INTEGRATIONS: 'integrations',
PROFILE: 'account',
MANAGE_USERS: 'manage-users',
MANAGE_ROLES: 'manage-roles',
MANAGE_USERS: 'team',
MANAGE_ROLES: 'roles',
SITES: 'projects',
CUSTOM_FIELDS: 'metadata',
WEBHOOKS: 'webhooks',
@ -73,7 +73,7 @@ export const client = (tab = routerClientTabString) => `/client/${ tab }`;
export const OB_TABS = {
INSTALLING: 'installing',
IDENTIFY_USERS: 'identify-users',
MANAGE_USERS: 'manage-users',
MANAGE_USERS: 'team',
INTEGRATIONS: 'integrations',
};
export const OB_DEFAULT_TAB = OB_TABS.INSTALLING;

View file

@ -117,4 +117,10 @@
.disabled {
opacity: 0.4;
pointer-events: none;
}
.hover {
&:hover {
background-color: $active-blue;
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-diagram-3" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M6 3.5A1.5 1.5 0 0 1 7.5 2h1A1.5 1.5 0 0 1 10 3.5v1A1.5 1.5 0 0 1 8.5 6v1H14a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 2 7h5.5V6A1.5 1.5 0 0 1 6 4.5v-1zM8.5 5a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1zM0 11.5A1.5 1.5 0 0 1 1.5 10h1A1.5 1.5 0 0 1 4 11.5v1A1.5 1.5 0 0 1 2.5 14h-1A1.5 1.5 0 0 1 0 12.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm4.5.5A1.5 1.5 0 0 1 7.5 10h1a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 14h-1A1.5 1.5 0 0 1 6 12.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm4.5.5a1.5 1.5 0 0 1 1.5-1.5h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View file

@ -1,18 +1,21 @@
import Record from 'Types/Record';
import { validateName } from 'App/validate';
import { notEmptyString, validateName } from 'App/validate';
import { List } from 'immutable';
export default Record({
roleId: undefined,
name: '',
allProjects: true,
permissions: List(),
projects: List(),
protected: false,
description: ''
description: '',
permissionOptions: List(),
}, {
idKey: 'roleId',
methods: {
validate() {
return validateName(this.name, { diacritics: true });
return notEmptyString(this.name) && validateName(this.name, { diacritics: true }) && (this.allProjects || this.projects.size > 0);
},
toData() {
const js = this.toJS();
@ -21,10 +24,11 @@ export default Record({
return js;
},
},
fromJS({ permissions, ...rest }) {
fromJS({ projects = [], permissions = [], ...rest }) {
return {
...rest,
permissions: List(permissions)
permissions: List(permissions),
projects: List(projects),
}
},
});