diff --git a/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx b/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx index 7d4d5526a..eae41c44e 100644 --- a/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx +++ b/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx @@ -32,9 +32,11 @@ interface Props { calling: CallingState, peerConnectionStatus: ConnectionStatus, remoteControlEnabled: boolean, + hasPermission: boolean, + isEnterprise: boolean, } -function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus, remoteControlEnabled }: Props) { +function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus, remoteControlEnabled, hasPermission, isEnterprise }: Props) { const [ incomeStream, setIncomeStream ] = useState(null); const [ localStream, setLocalStream ] = useState(null); const [ callObject, setCallObject ] = useState<{ end: ()=>void, toggleRemoteControl: ()=>void } | null >(null); @@ -64,6 +66,7 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus } const inCall = calling !== CallingState.False; + const cannotCall = (peerConnectionStatus !== ConnectionStatus.Connected) || (isEnterprise && !hasPermission) return (
@@ -73,8 +76,8 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus className={ cn( 'cursor-pointer p-2 mr-2 flex items-center', - {[stl.inCall] : inCall }, - {[stl.disabled]: peerConnectionStatus !== ConnectionStatus.Connected} + // {[stl.inCall] : inCall }, + {[stl.disabled]: cannotCall} ) } onClick={ inCall ? callObject?.end : call} @@ -118,7 +121,13 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus ) } -const con = connect(null, { toggleChatWindow }) +const con = connect(state => { + const permissions = state.getIn([ 'user', 'account', 'permissions' ]) || [] + return { + hasPermission: permissions.includes('ASSIST_CALL'), + isEnterprise: state.getIn([ 'user', 'client', 'edition' ]) === 'ee', + } +}, { toggleChatWindow }) export default con(connectPlayer(state => ({ calling: state.calling, diff --git a/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx b/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx index 0fc99b3ac..68176afbf 100644 --- a/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx +++ b/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { NoContent, Loader } from 'UI'; import { List, Map } from 'immutable'; import SessionItem from 'Shared/SessionItem'; +import withPermissions from 'HOCs/withPermissions' const AUTOREFRESH_INTERVAL = 1 * 60 * 1000 @@ -60,8 +61,13 @@ function LiveSessionList(props: Props) { ) } -export default connect(state => ({ - list: state.getIn(['sessions', 'liveSessions']), - loading: state.getIn([ 'sessions', 'loading' ]), - filters: state.getIn([ 'filters', 'appliedFilter' ]), -}), { fetchList })(LiveSessionList) +export default withPermissions(['ASSIST_LIVE'])(connect( + (state) => ({ + list: state.getIn(['sessions', 'liveSessions']), + loading: state.getIn([ 'sessions', 'loading' ]), + filters: state.getIn([ 'filters', 'appliedFilter' ]), + }), + { + fetchList + } +)(LiveSessionList)); diff --git a/frontend/app/components/Client/Client.js b/frontend/app/components/Client/Client.js index f8a3ee0c8..6cae36710 100644 --- a/frontend/app/components/Client/Client.js +++ b/frontend/app/components/Client/Client.js @@ -15,6 +15,7 @@ import styles from './client.css'; import cn from 'classnames'; import PreferencesMenu from './PreferencesMenu'; import Notifications from './Notifications'; +import Roles from './Roles'; @connect((state) => ({ appearance: state.getIn([ 'user', 'account', 'appearance' ]), @@ -42,6 +43,7 @@ export default class Client extends React.PureComponent { + ) diff --git a/frontend/app/components/Client/ManageUsers/ManageUsers.js b/frontend/app/components/Client/ManageUsers/ManageUsers.js index 0d49d5319..4dffc94c8 100644 --- a/frontend/app/components/Client/ManageUsers/ManageUsers.js +++ b/frontend/app/components/Client/ManageUsers/ManageUsers.js @@ -1,7 +1,9 @@ import { connect } from 'react-redux'; import cn from 'classnames'; import withPageTitle from 'HOCs/withPageTitle'; -import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup, CopyButton } from 'UI'; +import { + IconButton, SlideModal, Input, Button, Loader, + NoContent, Popup, CopyButton, Dropdown } from 'UI'; import { init, save, edit, remove as deleteMember, fetchList, generateInviteLink } from 'Duck/member'; import styles from './manageUsers.css'; import UserItem from './UserItem'; @@ -19,6 +21,7 @@ const LIMIT_WARNING = 'You have reached users limit.'; errors: state.getIn([ 'members', 'saveRequest', 'errors' ]), loading: state.getIn([ 'members', 'loading' ]), saving: state.getIn([ 'members', 'saveRequest', 'loading' ]), + roles: state.getIn(['roles', 'list']) }), { init, save, @@ -31,6 +34,7 @@ const LIMIT_WARNING = 'You have reached users limit.'; class ManageUsers extends React.PureComponent { state = { showModal: false, remaining: this.props.account.limits.teamMember.remaining, invited: false } + // writeOption = (e, { name, value }) => this.props.edit({ [ name ]: value }); onChange = (e, { name, value }) => this.props.edit({ [ name ]: value }); onChangeCheckbox = ({ target: { checked, name } }) => this.props.edit({ [ name ]: checked }); setFocus = () => this.focusElement.focus(); @@ -76,81 +80,97 @@ class ManageUsers extends React.PureComponent { }); } - formContent = (member, account) => ( -
-
-
- - { this.focusElement = ref; } } - name="name" - value={ member.name } - onChange={ this.onChange } - className={ styles.input } - id="name-field" - /> -
+ formContent = (member, account, roles) => { + const options = roles.map(r => ({ text: r.name, value: r.roleId })).toJS(); -
- - -
- { !account.smtp && -
- SMTP is not configured. Please follow (see here how to set it up). You can still add new users, but you’d have to manually copy then send them the invitation link. -
- } -
-
-
-
- - +
+ + +
+ { !account.smtp && +
+ SMTP is not configured. Please follow (see here how to set it up). You can still add new users, but you’d have to manually copy then send them the invitation link. +
+ } +
+ +
{ 'Can manage Projects and team members.' }
+
+ +
+ + +
+ + +
+
+ + +
+ { !member.joined && member.invitationLink && + + }
- { !member.joined && member.invitationLink && - - }
-
- ) + ) + } init = (v) => { this.props.init(v); @@ -160,7 +180,7 @@ class ManageUsers extends React.PureComponent { render() { const { - members, member, loading, account, hideHeader = false, + members, member, loading, account, hideHeader = false, roles } = this.props; const { showModal, remaining, invited } = this.state; const isAdmin = account.admin || account.superAdmin; @@ -173,7 +193,7 @@ class ManageUsers extends React.PureComponent { title="Inivte People" size="small" isDisplayed={ showModal } - content={ this.formContent(member, account) } + content={ this.formContent(member, account, roles) } onClose={ this.closeModal } />
diff --git a/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js b/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js index 4a1ff3f1d..f139afbe9 100644 --- a/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js +++ b/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js @@ -6,7 +6,7 @@ import stl from './preferencesMenu.css'; import { CLIENT_TABS, client as clientRoute } from 'App/routes'; import { withRouter } from 'react-router-dom'; -function PreferencesMenu({ activeTab, appearance, history }) { +function PreferencesMenu({ activeTab, appearance, history, isEnterprise }) { const setTab = (tab) => { history.push(clientRoute(tab)); @@ -76,7 +76,18 @@ function PreferencesMenu({ activeTab, appearance, history }) { iconName="users" onClick={() => setTab(CLIENT_TABS.MANAGE_USERS) } /> -
+
+ + { isEnterprise && ( +
+ setTab(CLIENT_TABS.MANAGE_ROLES) } + /> +
+ )}
({ appearance: state.getIn([ 'user', 'account', 'appearance' ]), + isEnterprise: state.getIn([ 'user', 'client', 'edition' ]) === 'ee', }))(withRouter(PreferencesMenu)); diff --git a/frontend/app/components/Client/Roles/Roles.tsx b/frontend/app/components/Client/Roles/Roles.tsx new file mode 100644 index 000000000..9198a7532 --- /dev/null +++ b/frontend/app/components/Client/Roles/Roles.tsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from 'react' +import cn from 'classnames' +import { Loader, IconButton, Popup, NoContent, SlideModal } from 'UI' +import { connect } from 'react-redux' +import stl from './roles.css' +import RoleForm from './components/RoleForm' +import { init, edit, fetchList, remove as deleteRole } from 'Duck/roles'; +import RoleItem from './components/RoleItem' +import { confirm } from 'UI/Confirmation'; + +interface Props { + loading: boolean + init: (role?: any) => void, + edit: (role: any) => void, + instance: any, + roles: any[], + deleteRole: (id: any) => void, + fetchList: () => Promise, +} + +function Roles(props: Props) { + const { loading, instance, roles, init, edit, deleteRole } = props + const [showModal, setShowmModal] = useState(false) + + useEffect(() => { + props.fetchList() + }, []) + + const closeModal = () => { + setShowmModal(false) + setTimeout(() => { + init() + }, 100) + } + + const editHandler = role => { + init(role) + setShowmModal(true) + } + + const deleteHandler = async (role) => { + if (await confirm({ + header: 'Roles', + confirmation: `Are you sure you want to remove this role?` + })) { + deleteRole(role.roleId) + } + } + + return ( + + + } + onClose={ closeModal } + /> +
+
+
+

Manage Roles and Permissions

+ + setShowmModal(true) } + /> +
+ } + size="tiny" + inverted + position="top left" + /> +
+
+ + +
+ {roles.map(role => ( + + ))} +
+
+
+ + + ) +} + +export default connect(state => ({ + instance: state.getIn(['roles', 'instance']) || null, + roles: state.getIn(['roles', 'list']), + loading: state.getIn(['roles', 'fetchRequest', 'loading']), +}), { init, edit, fetchList, deleteRole })(Roles) \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/Permissions/Permissions.tsx b/frontend/app/components/Client/Roles/components/Permissions/Permissions.tsx new file mode 100644 index 000000000..0dd56dfd9 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/Permissions/Permissions.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import Role from 'Types/role' + +interface Props { + role: Role +} +function Permissions(props: Props) { + return ( +
+ +
+ ); +} + +export default Permissions; \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/Permissions/index.ts b/frontend/app/components/Client/Roles/components/Permissions/index.ts new file mode 100644 index 000000000..659544a53 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/Permissions/index.ts @@ -0,0 +1 @@ +export { default } from './Permissions'; \ 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 new file mode 100644 index 000000000..d12b60269 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx @@ -0,0 +1,100 @@ +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' + +interface Permission { + name: string, + value: string +} + +interface Props { + role: any, + edit: (role: any) => void, + save: (role: any) => Promise, + closeModal: () => void, + saving: boolean, + permissions: Array[] +} + +const RoleForm = ({ role, closeModal, edit, save, saving, permissions }: Props) => { + let focusElement = useRef(null) + const _save = () => { + save(role).then(() => { + closeModal() + }) + } + + const write = ({ target: { value, name } }) => edit({ [ name ]: value }) + + const onChangeOption = (e) => { + const { permissions } = role + const index = permissions.indexOf(e) + const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e) + edit({ permissions: _perms }) + } + + useEffect(() => { + focusElement && focusElement.current && focusElement.current.focus() + }, []) + + return ( +
+
+
+ + +
+ +
+ { permissions.map((permission: any, index) => ( +
+ onChangeOption(permission.value) } + label={permission.name} + /> +
+ ))} +
+
+ +
+
+ + +
+
+
+ ); +} + +export default connect(state => ({ + role: state.getIn(['roles', 'instance']), + permissions: state.getIn(['roles', 'permissions']), +}), { edit, save })(RoleForm); \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/RoleForm/index.ts b/frontend/app/components/Client/Roles/components/RoleForm/index.ts new file mode 100644 index 000000000..3bb62ee58 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/RoleForm/index.ts @@ -0,0 +1 @@ +export { default } from './RoleForm'; \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css b/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css new file mode 100644 index 000000000..a0c5934c8 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/RoleForm/roleForm.css @@ -0,0 +1,21 @@ +.form { + padding: 0 20px; + + & .formGroup { + margin-bottom: 15px; + } + & label { + display: block; + margin-bottom: 5px; + font-weight: 500; + } + + & .input { + width: 100%; + } + + & input[type=checkbox] { + margin-right: 10px; + height: 13px; + } +} \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx b/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx new file mode 100644 index 000000000..a242ea6f2 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Icon } from 'UI' +import stl from './roleItem.css' +import cn from 'classnames' + +interface Props { + role: any, + deleteHandler?: (role: any) => void, + editHandler?: (role: any) => void, +} +function RoleItem({ role, deleteHandler, editHandler }: Props) { + return ( +
+ + { role.name } + +
+ { !!deleteHandler && +
deleteHandler(role) } id="trash"> + +
+ } + { !!editHandler && +
editHandler(role) }> + +
+ } +
+
+ ); +} + +export default RoleItem; \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/RoleItem/index.ts b/frontend/app/components/Client/Roles/components/RoleItem/index.ts new file mode 100644 index 000000000..645d37fd1 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/RoleItem/index.ts @@ -0,0 +1 @@ +export { default } from './RoleItem' \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css b/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css new file mode 100644 index 000000000..50a56afb4 --- /dev/null +++ b/frontend/app/components/Client/Roles/components/RoleItem/roleItem.css @@ -0,0 +1,37 @@ +.wrapper { + display: flex; + align-items: center; + width: 100%; + border-bottom: solid thin #e6e6e6; + padding: 10px 0px; +} + +.actions { + margin-left: auto; + /* opacity: 0; */ + transition: all 0.4s; + display: flex; + align-items: center; + & .button { + padding: 5px; + cursor: pointer; + margin-left: 10px; + display: flex; + align-items: center; + justify-content: center; + &:hover { + & svg { + fill: $teal-dark; + } + } + &.disabled { + pointer-events: none; + opacity: 0.5; + } + } + + & .disabled { + pointer-events: none; + opacity: 0.5; + } +} \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/index.ts b/frontend/app/components/Client/Roles/index.ts new file mode 100644 index 000000000..9e6fe3912 --- /dev/null +++ b/frontend/app/components/Client/Roles/index.ts @@ -0,0 +1 @@ +export { default } from './Roles'; \ No newline at end of file diff --git a/frontend/app/components/Client/Roles/roles.css b/frontend/app/components/Client/Roles/roles.css new file mode 100644 index 000000000..819111686 --- /dev/null +++ b/frontend/app/components/Client/Roles/roles.css @@ -0,0 +1,13 @@ +.wrapper { + padding: 0; +} +.tabHeader { + display: flex; + align-items: center; + margin-bottom: 25px; + + & .tabTitle { + margin: 0 15px 0 0; + font-weight: 400 !important; + } +} \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Dashboard.js b/frontend/app/components/Dashboard/Dashboard.js index ffbc87b41..2e5cba630 100644 --- a/frontend/app/components/Dashboard/Dashboard.js +++ b/frontend/app/components/Dashboard/Dashboard.js @@ -1,6 +1,7 @@ import { connect } from 'react-redux'; import cn from 'classnames'; import withPageTitle from 'HOCs/withPageTitle'; +import withPermissions from 'HOCs/withPermissions' import { setPeriod, setPlatform, fetchMetadataOptions } from 'Duck/dashboard'; import { NoContent } from 'UI'; import { WIDGET_KEYS } from 'Types/dashboard'; @@ -103,6 +104,7 @@ function isInViewport(el) { ); } +@withPermissions(['METRICS'], 'page-margin container-90') @connect(state => ({ period: state.getIn([ 'dashboard', 'period' ]), comparing: state.getIn([ 'dashboard', 'comparing' ]), diff --git a/frontend/app/components/Errors/Errors.js b/frontend/app/components/Errors/Errors.js index b4c5fce92..f9e7b5c9b 100644 --- a/frontend/app/components/Errors/Errors.js +++ b/frontend/app/components/Errors/Errors.js @@ -1,5 +1,6 @@ import { connect } from 'react-redux'; import withSiteIdRouter from 'HOCs/withSiteIdRouter'; +import withPermissions from 'HOCs/withPermissions' import { UNRESOLVED, RESOLVED, IGNORED } from "Types/errorInfo"; import { getRE } from 'App/utils'; import { fetchBookmarks } from "Duck/errors"; @@ -33,6 +34,7 @@ function getStatusLabel(status) { } } +@withPermissions(['ERRORS'], 'page-margin container-90') @withSiteIdRouter @connect(state => ({ list: state.getIn([ "errors", "list" ]), diff --git a/frontend/app/components/Funnels/FunnelHeader/FunnelDropdown.js b/frontend/app/components/Funnels/FunnelHeader/FunnelDropdown.js index 88715c174..87f7983b7 100644 --- a/frontend/app/components/Funnels/FunnelHeader/FunnelDropdown.js +++ b/frontend/app/components/Funnels/FunnelHeader/FunnelDropdown.js @@ -9,7 +9,6 @@ function FunnelDropdown(props) { const writeOption = (e, { name, value }) => { const { siteId, history } = props; - console.log(value) history.push(withSiteId(funnelRoute(parseInt(value)), siteId)); } diff --git a/frontend/app/components/Session_/Player/Controls/Controls.js b/frontend/app/components/Session_/Player/Controls/Controls.js index 266cf1cd1..e3e3bcc7a 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.js +++ b/frontend/app/components/Session_/Player/Controls/Controls.js @@ -97,12 +97,17 @@ function getStorageName(type) { showExceptions: state.exceptionsList.length > 0, showLongtasks: state.longtasksList.length > 0, })) -@connect((state, props) => ({ - fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), - bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]), - showStorage: props.showStorage || !state.getIn(['components', 'player', 'hiddenHints', 'storage']), - showStack: props.showStack || !state.getIn(['components', 'player', 'hiddenHints', 'stack']), -}), { +@connect((state, props) => { + const permissions = state.getIn([ 'user', 'account', 'permissions' ]) || []; + const isEnterprise = state.getIn([ 'user', 'client', 'edition' ]) === 'ee'; + return { + disabled: props.disabled || (isEnterprise && !permissions.includes('DEV_TOOLS')), + fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), + bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]), + showStorage: props.showStorage || !state.getIn(['components', 'player', 'hiddenHints', 'storage']), + showStack: props.showStack || !state.getIn(['components', 'player', 'hiddenHints', 'stack']), + } +}, { fullscreenOn, fullscreenOff, toggleBottomBlock, diff --git a/frontend/app/components/hocs/withPermissions.js b/frontend/app/components/hocs/withPermissions.js new file mode 100644 index 000000000..c7a48609c --- /dev/null +++ b/frontend/app/components/hocs/withPermissions.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { NoPermission } from 'UI'; + +export default (requiredPermissions, className) => BaseComponent => +@connect((state, props) => ({ + permissions: state.getIn([ 'user', 'account', 'permissions' ]), + isEnterprise: state.getIn([ 'user', 'client', 'edition' ]) === 'ee', +})) +class extends React.PureComponent { + render() { + const hasPermission = this.props.permissions.some(permission => requiredPermissions.includes(permission)); + + return !this.props.isEnterprise || hasPermission ? :
+ } +} \ No newline at end of file diff --git a/frontend/app/components/ui/NoPermission/NoPermission.tsx b/frontend/app/components/ui/NoPermission/NoPermission.tsx new file mode 100644 index 000000000..eaf43d4aa --- /dev/null +++ b/frontend/app/components/ui/NoPermission/NoPermission.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import stl from './noPermission.css' +import { Icon } from 'UI'; + +function NoPermission(props) { + return ( +
+ +
Not allowed
+ You don’t have the necessary permissions to access this feature. Please check with your admin. +
+ ); +} + +export default NoPermission; \ No newline at end of file diff --git a/frontend/app/components/ui/NoPermission/index.ts b/frontend/app/components/ui/NoPermission/index.ts new file mode 100644 index 000000000..c826daf1d --- /dev/null +++ b/frontend/app/components/ui/NoPermission/index.ts @@ -0,0 +1 @@ +export { default } from './NoPermission'; \ No newline at end of file diff --git a/frontend/app/components/ui/NoPermission/noPermission.css b/frontend/app/components/ui/NoPermission/noPermission.css new file mode 100644 index 000000000..f4296757c --- /dev/null +++ b/frontend/app/components/ui/NoPermission/noPermission.css @@ -0,0 +1,59 @@ +.wrapper { + margin: auto; + width: 100%; + text-align: center; + min-height: 100px; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + color: $gray-medium; + font-weight: 300; + transition: all 0.2s; + padding-top: 40px; + + &.small { + & .title { + font-size: 20px !important; + } + + & .subtext { + font-size: 16px; + } + } +} + +.title { + font-size: 32px; + margin-bottom: 15px; +} + +.subtext { + font-size: 16px; + margin-bottom: 20px; +} + + +.icon { + display: block; + margin: auto; + background-image: svg-load(no-results.svg, fill=#CCC); + background-repeat: no-repeat; + background-size: contain; + background-position: center center; + width: 166px; + height: 166px; + margin-bottom: 20px; +} + +.emptyIcon { + display: block; + margin: auto; + background-image: svg-load(empty-state.svg, fill=#CCC); + background-repeat: no-repeat; + background-size: contain; + background-position: center center; + width: 166px; + height: 166px; + margin-bottom: 20px; +} diff --git a/frontend/app/components/ui/index.js b/frontend/app/components/ui/index.js index fe9609f16..669be843a 100644 --- a/frontend/app/components/ui/index.js +++ b/frontend/app/components/ui/index.js @@ -52,5 +52,6 @@ export { default as QuestionMarkHint } from './QuestionMarkHint'; export { default as TimelinePointer } from './TimelinePointer'; export { default as CopyButton } from './CopyButton'; export { default as HighlightCode } from './HighlightCode'; +export { default as NoPermission } from './NoPermission'; export { Input, Modal, Form, Message, Card } from 'semantic-ui-react'; diff --git a/frontend/app/duck/index.js b/frontend/app/duck/index.js index 53771ca04..c8d7a7c65 100644 --- a/frontend/app/duck/index.js +++ b/frontend/app/duck/index.js @@ -33,6 +33,7 @@ import announcements from './announcements'; import errors from './errors'; import funnels from './funnels'; import config from './config'; +import roles from './roles'; export default combineReducers({ jwt, @@ -66,6 +67,7 @@ export default combineReducers({ errors, funnels, config, + roles, ...integrations, ...sources, }); diff --git a/frontend/app/duck/roles.js b/frontend/app/duck/roles.js new file mode 100644 index 000000000..abc3ce0f4 --- /dev/null +++ b/frontend/app/duck/roles.js @@ -0,0 +1,32 @@ +import { List, Map } from 'immutable'; +import Role from 'Types/role'; +import crudDuckGenerator from './tools/crudDuck'; +import { reduceDucks } from 'Duck/tools'; + +const crudDuck = crudDuckGenerator('client/role', Role, { idKey: 'roleId' }); +export const { fetchList, init, edit, remove, } = crudDuck.actions; + +const initialState = Map({ + list: List(), + permissions: List([ + { name: 'Session Replay', value: 'SESSION_REPLAY' }, + { name: 'Develoepr Tools', value: 'DEV_TOOLS' }, + { name: 'Errors', value: 'ERRORS' }, + { name: 'Metrics', value: 'METRICS' }, + { name: 'Assist Live', value: 'ASSIST_LIVE' }, + { name: 'Assist Call', value: 'ASSIST_CALL' }, + ]) +}); + +const reducer = (state = initialState, action = {}) => { + return state; +}; + +export function save(instance) { + return { + types: crudDuck.actionTypes.SAVE.toArray(), + call: client => instance.roleId ? client.post(`/client/roles/${ instance.roleId }`, instance.toData()) : client.put(`/client/roles`, instance.toData()), + }; +} + +export default reduceDucks(crudDuck, { initialState, reducer }).reducer; diff --git a/frontend/app/routes.js b/frontend/app/routes.js index df032fe32..2ca5fd672 100644 --- a/frontend/app/routes.js +++ b/frontend/app/routes.js @@ -60,6 +60,7 @@ export const CLIENT_TABS = { INTEGRATIONS: 'integrations', PROFILE: 'account', MANAGE_USERS: 'manage-users', + MANAGE_ROLES: 'manage-roles', SITES: 'projects', CUSTOM_FIELDS: 'metadata', WEBHOOKS: 'webhooks', diff --git a/frontend/app/svg/icons/shield-lock.svg b/frontend/app/svg/icons/shield-lock.svg new file mode 100644 index 000000000..1a1a49084 --- /dev/null +++ b/frontend/app/svg/icons/shield-lock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/types/account/account.js b/frontend/app/types/account/account.js index 2493a692d..28822b194 100644 --- a/frontend/app/types/account/account.js +++ b/frontend/app/types/account/account.js @@ -13,6 +13,7 @@ export default Member.extend({ smtp: false, license: '', expirationDate: undefined, + permissions: [], }, { fromJS: ({ current = {}, ...account})=> ({ ...account, diff --git a/frontend/app/types/client/client.js b/frontend/app/types/client/client.js index c1a33114f..542ff1a97 100644 --- a/frontend/app/types/client/client.js +++ b/frontend/app/types/client/client.js @@ -10,7 +10,8 @@ export default Record({ tenantId: undefined, name: undefined, sites: List(), - optOut: true + optOut: true, + edition: '', }, { fromJS: ({ projects, diff --git a/frontend/app/types/member.js b/frontend/app/types/member.js index f712c7347..4c064e90d 100644 --- a/frontend/app/types/member.js +++ b/frontend/app/types/member.js @@ -11,7 +11,8 @@ export default Record({ superAdmin: false, joined: false, expiredInvitation: false, - invitationLink: '' + roleId: undefined, + invitationLink: '', }, { idKey: 'id', methods: { diff --git a/frontend/app/types/role.js b/frontend/app/types/role.js new file mode 100644 index 000000000..52a74d400 --- /dev/null +++ b/frontend/app/types/role.js @@ -0,0 +1,30 @@ +import Record from 'Types/Record'; +import { validateName } from 'App/validate'; +import { List } from 'immutable'; + +export default Record({ + roleId: undefined, + name: '', + permissions: List(), + protected: false, + description: '' +}, { + idKey: 'roleId', + methods: { + validate() { + return validateName(this.name, { diacritics: true }); + }, + toData() { + const js = this.toJS(); + delete js.key; + delete js.protected; + return js; + }, + }, + fromJS({ permissions, ...rest }) { + return { + ...rest, + permissions: List(permissions) + } + }, +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b5d3a9688..16cba8159 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -19,8 +19,11 @@ "Types": ["./app/types" ], "Types/*": ["./app/types/*"], // Sublime hack "UI": ["./app/components/ui"], + "UI/*": ["./app/components/ui/*"], "Duck": ["./app/duck"], "Duck/*": ["./app/duck/*"], + "HOCs": ["./app/components/hocs"], + "HOCs/*": ["./app/components/hocs/*"], "Shared": ["./app/components/shared"], "Shared/*": ["./app/components/shared/*"], "Player": ["./app/player"],