diff --git a/frontend/app/Router.js b/frontend/app/Router.js index edacf56c5..c3a1721a6 100644 --- a/frontend/app/Router.js +++ b/frontend/app/Router.js @@ -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 }); } diff --git a/frontend/app/components/Client/ManageUsers/ManageUsers.js b/frontend/app/components/Client/ManageUsers/ManageUsers.js index e157db019..9071ee46b 100644 --- a/frontend/app/components/Client/ManageUsers/ManageUsers.js +++ b/frontend/app/components/Client/ManageUsers/ManageUsers.js @@ -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 } diff --git a/frontend/app/components/Client/Notifications/Notifications.js b/frontend/app/components/Client/Notifications/Notifications.js index 08db0a3b9..31e6e3699 100644 --- a/frontend/app/components/Client/Notifications/Notifications.js +++ b/frontend/app/components/Client/Notifications/Notifications.js @@ -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)); diff --git a/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js b/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js index fa3d51db9..0c57c1ab7 100644 --- a/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js +++ b/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js @@ -68,25 +68,25 @@ function PreferencesMenu({ activeTab, appearance, history, isEnterprise }) { /> -
- setTab(CLIENT_TABS.MANAGE_USERS) } - /> -
- { isEnterprise && (
setTab(CLIENT_TABS.MANAGE_ROLES) } />
)} + +
+ setTab(CLIENT_TABS.MANAGE_USERS) } + /> +
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) { } + content={ showModal && } onClose={ closeModal } />
-

Manage Roles and Permissions

+

Roles and Access

@@ -111,11 +113,17 @@ function Roles(props: Props) { icon >
+
+
Title
+
Project Access
+
Feature Access
+
{roles.map(role => ( @@ -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) \ No newline at end of file +}, { init, edit, fetchList, deleteRole, resetErrors })(withPageTitle('Roles & Access - OpenReplay Preferences')(Roles)) \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx b/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx index a999a9edf..ce535c9dd 100644 --- a/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx +++ b/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx @@ -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[] + projectOptions: Array[], + permissionsMap: any, + projectsMap: any, + deleteHandler: (id: any) => Promise, } -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(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 (
-
- +
+
-
- { permissions.map((permission: any, index) => ( -
- onChangeOption(permission.value) } - label={permission.name} - /> +
+ + +
+ +
+
All Projects
+ + (Uncheck to select specific projects) +
- ))} +
+ { !role.allProjects && ( + <> + + { role.projects.size > 0 && ( +
+ { role.projects.map(p => ( + OptionLabel(projectsMap, p, onChangeProjects) + )) } +
+ )} + + )} +
+ +
+ + + { role.permissions.size > 0 && ( +
+ { role.permissions.map(p => ( + OptionLabel(permissionsMap, p, onChangePermissions) + )) } +
+ )}
@@ -89,13 +169,50 @@ const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props) { 'Cancel' }
+ { role.exists() && ( +
+ +
+ )}
); } -export default connect(state => ({ - role: state.getIn(['roles', 'instance']), - permissions: state.getIn(['roles', 'permissions']), - saving: state.getIn([ 'roles', 'saveRequest', 'loading' ]), -}), { edit, save })(RoleForm); \ No newline at end of file +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
+
{nameMap[p]}
+
onChangeOption(p)}> + +
+
+} diff --git a/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css b/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css index a0c5934c8..eed9e22b4 100644 --- a/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css +++ b/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css @@ -1,9 +1,5 @@ .form { padding: 0 20px; - - & .formGroup { - margin-bottom: 15px; - } & label { display: block; margin-bottom: 5px; diff --git a/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx b/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx index 2ef271a46..dc67dd42d 100644 --- a/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx +++ b/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx @@ -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 ( -
{ permission }
+
{ label }
+ ); +} + +function PermisionLabelLinked({ label, route }: any) { + return ( +
{ label }
); } @@ -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 ( -
- -
-
{ role.name }
-
- {role.permissions.map((permission: any) => ( - - // { permissions[permission].name } - ))} -
+
+
+ + { role.name }
+
+ {role.allProjects ? ( + + ) : ( + role.projects.map(p => ( + + )) + )} +
+
+ {role.permissions.map((permission: any) => ( + + ))} +
+ { isAdmin && ( -
- { !!deleteHandler && -
deleteHandler(role) } id="trash"> - -
- } +
{ !!editHandler &&
editHandler(role) }> @@ -43,7 +56,6 @@ function RoleItem({ role, deleteHandler, editHandler, isAdmin, permissions }: Pr }
)} -
); } diff --git a/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css b/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css index af0aab35d..e5d3224ba 100644 --- a/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css +++ b/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css @@ -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; } \ No newline at end of file diff --git a/frontend/app/components/Client/Sites/Sites.js b/frontend/app/components/Client/Sites/Sites.js index 9b5e3b9b0..4769815e5 100644 --- a/frontend/app/components/Client/Sites/Sites.js +++ b/frontend/app/components/Client/Sites/Sites.js @@ -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, diff --git a/frontend/app/components/Errors/Errors.js b/frontend/app/components/Errors/Errors.js index f9e7b5c9b..4eb671cf5 100644 --- a/frontend/app/components/Errors/Errors.js +++ b/frontend/app/components/Errors/Errors.js @@ -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, diff --git a/frontend/app/duck/roles.js b/frontend/app/duck/roles.js index 874bd9be6..8a8415475 100644 --- a/frontend/app/duck/roles.js +++ b/frontend/app/duck/roles.js @@ -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; }; diff --git a/frontend/app/routes.js b/frontend/app/routes.js index 2ca5fd672..cdccc6327 100644 --- a/frontend/app/routes.js +++ b/frontend/app/routes.js @@ -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; diff --git a/frontend/app/styles/main.css b/frontend/app/styles/main.css index f27155e36..fc366bc39 100644 --- a/frontend/app/styles/main.css +++ b/frontend/app/styles/main.css @@ -117,4 +117,10 @@ .disabled { opacity: 0.4; pointer-events: none; +} + +.hover { + &:hover { + background-color: $active-blue; + } } \ No newline at end of file diff --git a/frontend/app/svg/icons/diagram-3.svg b/frontend/app/svg/icons/diagram-3.svg new file mode 100644 index 000000000..03e448c17 --- /dev/null +++ b/frontend/app/svg/icons/diagram-3.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/types/role.js b/frontend/app/types/role.js index 52a74d400..d63afa5db 100644 --- a/frontend/app/types/role.js +++ b/frontend/app/types/role.js @@ -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), } }, });