Merge pull request #447 from openreplay/user-list
UI Improvements - User, Projects
This commit is contained in:
commit
17aec98298
25 changed files with 951 additions and 59 deletions
|
|
@ -7,6 +7,7 @@ import { fetchList as fetchMemberList } from 'Duck/member';
|
|||
import ProfileSettings from './ProfileSettings';
|
||||
import Integrations from './Integrations';
|
||||
import ManageUsers from './ManageUsers';
|
||||
import UserView from './Users/UsersView';
|
||||
import Sites from './Sites';
|
||||
import CustomFields from './CustomFields';
|
||||
import Webhooks from './Webhooks';
|
||||
|
|
@ -36,7 +37,7 @@ export default class Client extends React.PureComponent {
|
|||
<Switch>
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.PROFILE) } component={ ProfileSettings } />
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.INTEGRATIONS) } component={ Integrations } />
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.MANAGE_USERS) } component={ ManageUsers } />
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.MANAGE_USERS) } component={ UserView } />
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.SITES) } component={ Sites } />
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.CUSTOM_FIELDS) } component={ CustomFields } />
|
||||
<Route exact strict path={ clientRoute(CLIENT_TABS.WEBHOOKS) } component={ Webhooks } />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { Input, Button, Label } from 'UI';
|
||||
import { save, edit, update , fetchList } from 'Duck/site';
|
||||
import { Input, Button, Icon } from 'UI';
|
||||
import { save, edit, update , fetchList, remove } from 'Duck/site';
|
||||
import { pushNewSite } from 'Duck/user';
|
||||
import { setSiteId } from 'Duck/site';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import styles from './siteForm.css';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
|
||||
@connect(state => ({
|
||||
site: state.getIn([ 'site', 'instance' ]),
|
||||
|
|
@ -13,6 +14,7 @@ import styles from './siteForm.css';
|
|||
loading: state.getIn([ 'site', 'save', 'loading' ]),
|
||||
}), {
|
||||
save,
|
||||
remove,
|
||||
edit,
|
||||
update,
|
||||
pushNewSite,
|
||||
|
|
@ -52,6 +54,17 @@ export default class NewSiteForm extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
remove = async (site) => {
|
||||
if (await confirm({
|
||||
header: 'Projects',
|
||||
confirmation: `Are you sure you want to delete this Project? We won't be able to record anymore sessions.`
|
||||
})) {
|
||||
this.props.remove(site.id).then(() => {
|
||||
this.props.onClose(null)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
edit = ({ target: { name, value } }) => {
|
||||
this.setState({ existsError: false });
|
||||
this.props.edit({ [ name ]: value });
|
||||
|
|
@ -72,7 +85,7 @@ export default class NewSiteForm extends React.PureComponent {
|
|||
className={ styles.input }
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mt-6 flex justify-between">
|
||||
<Button
|
||||
primary
|
||||
type="submit"
|
||||
|
|
@ -80,7 +93,10 @@ export default class NewSiteForm extends React.PureComponent {
|
|||
loading={ loading }
|
||||
content={site.exists() ? 'Update' : 'Add'}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" plain onClick={() => this.remove(site)}>
|
||||
<Icon name="trash" size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{ this.state.existsError &&
|
||||
<div className={ styles.errorMessage }>
|
||||
{ "Site exists already. Please choose another one." }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { debounce } from 'App/utils';
|
||||
|
||||
let debounceUpdate: any = () => {}
|
||||
interface Props {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function SiteSearch(props: Props) {
|
||||
const { onChange } = props;
|
||||
|
||||
useEffect(() => {
|
||||
debounceUpdate = debounce((value) => onChange(value), 500);
|
||||
}, [])
|
||||
|
||||
const write = ({ target: { name, value } }) => {
|
||||
debounceUpdate(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: '300px'}}>
|
||||
<Icon name="search" className="absolute top-0 bottom-0 ml-3 m-auto" size="16" />
|
||||
<input
|
||||
// value={query}
|
||||
name="searchQuery"
|
||||
className="bg-white p-2 border border-gray-light rounded w-full pl-10"
|
||||
placeholder="Filter by Name"
|
||||
onChange={write}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SiteSearch;
|
||||
1
frontend/app/components/Client/Sites/SiteSearch/index.ts
Normal file
1
frontend/app/components/Client/Sites/SiteSearch/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SiteSearch';
|
||||
|
|
@ -10,6 +10,7 @@ import GDPRForm from './GDPRForm';
|
|||
import TrackingCodeModal from 'Shared/TrackingCodeModal';
|
||||
import BlockedIps from './BlockedIps';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
import SiteSearch from './SiteSearch';
|
||||
|
||||
const STATUS_MESSAGE_MAP = {
|
||||
[ RED ]: ' There seems to be an issue (please verify your installation)',
|
||||
|
|
@ -43,6 +44,7 @@ class Sites extends React.PureComponent {
|
|||
showTrackingCode: false,
|
||||
modalContent: NONE,
|
||||
detailContent: NONE,
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
toggleBlockedIp = () => {
|
||||
|
|
@ -85,7 +87,7 @@ class Sites extends React.PureComponent {
|
|||
getModalTitle() {
|
||||
switch (this.state.modalContent) {
|
||||
case NEW_SITE_FORM:
|
||||
return 'New Project';
|
||||
return this.props.site.exists() ? 'Update Project' : 'New Project';
|
||||
case GDPR_FORM:
|
||||
return 'Project Settings';
|
||||
default:
|
||||
|
|
@ -119,6 +121,7 @@ class Sites extends React.PureComponent {
|
|||
const isAdmin = user.admin || user.superAdmin;
|
||||
const canAddSites = isAdmin && account.limits.projects && account.limits.projects.remaining !== 0;
|
||||
const canDeleteSites = sites.size > 1 && isAdmin;
|
||||
const filteredSites = sites.filter(site => site.name.toLowerCase().includes(this.state.searchQuery.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Loader loading={ loading }>
|
||||
|
|
@ -159,54 +162,71 @@ class Sites extends React.PureComponent {
|
|||
position="top left"
|
||||
/>
|
||||
|
||||
<TextLink
|
||||
icon="book"
|
||||
className="ml-auto"
|
||||
href="https://docs.openreplay.com/installation"
|
||||
label="Documentation"
|
||||
/>
|
||||
<div className="flex ml-auto items-center">
|
||||
<TextLink
|
||||
icon="book"
|
||||
className="mr-4"
|
||||
href="https://docs.openreplay.com/installation"
|
||||
label="Documentation"
|
||||
/>
|
||||
<SiteSearch onChange={(value) => this.setState({ searchQuery: value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={ stl.list }>
|
||||
<div className="grid grid-cols-12 gap-2 w-full items-center border-b px-2 py-3 font-medium">
|
||||
<div className="col-span-4">Name</div>
|
||||
<div className="col-span-4">Key</div>
|
||||
<div className="col-span-4"></div>
|
||||
|
||||
</div>
|
||||
{
|
||||
sites.map(_site => (
|
||||
<div key={ _site.key } className={ stl.site } data-inactive={ _site.status === RED }>
|
||||
<div className="flex items-center">
|
||||
<Popup
|
||||
trigger={
|
||||
<div style={ { width: '10px' } }>
|
||||
<Icon name="circle" size="10" color={ STATUS_COLOR_MAP[ _site.status ] } />
|
||||
filteredSites.map(_site => (
|
||||
// <div key={ _site.key } data-inactive={ _site.status === RED }>
|
||||
<div key={ _site.key } className="grid grid-cols-12 gap-2 w-full group hover:bg-active-blue items-center border-b px-2 py-3">
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center">
|
||||
<Popup
|
||||
trigger={
|
||||
<div style={ { width: '10px' } }>
|
||||
<Icon name="circle" size="10" color={ STATUS_COLOR_MAP[ _site.status ] } />
|
||||
</div>
|
||||
}
|
||||
content={ STATUS_MESSAGE_MAP[ _site.status ] }
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
<span className="ml-2">{ _site.host }</span>
|
||||
</div>
|
||||
}
|
||||
content={ STATUS_MESSAGE_MAP[ _site.status ] }
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
<div className="ml-3 flex items-center">
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<span className="px-2 py-1 bg-gray-lightest rounded border text-sm">{_site.projectKey}</span>
|
||||
</div>
|
||||
{/* <div className="ml-3 flex items-center">
|
||||
<div>{ _site.host }</div>
|
||||
<div className={ stl.label}>{_site.projectKey}</div>
|
||||
</div> */}
|
||||
<div className="col-span-4 justify-self-end flex items-center invisible group-hover:visible">
|
||||
<div className="mr-4"><Button size="small" primary onClick={ () => this.showTrackingCode(_site) }>{ 'Installation' }</Button></div>
|
||||
{/* <button
|
||||
className={cn('mx-3', {'hidden' : !canDeleteSites})}
|
||||
disabled={ !canDeleteSites }
|
||||
onClick={ () => canDeleteSites && this.remove(_site) }
|
||||
>
|
||||
<Icon name="trash" size="16" color="teal" />
|
||||
</button> */}
|
||||
<button
|
||||
className={cn('mx-3', {'hidden' : !isAdmin})}
|
||||
disabled={ !isAdmin }
|
||||
onClick={ () => isAdmin && this.edit(_site) }
|
||||
data-clickable
|
||||
>
|
||||
<Icon name="edit" size="16" color="teal"/>
|
||||
</button>
|
||||
{/* <button disabled={ !isAdmin } onClick={ () => this.showGDPRForm(_site) } ><Icon name="cog" size="16" color="teal" /></button> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className={ stl.actions }>
|
||||
<button
|
||||
className={cn({'hidden' : !canDeleteSites})}
|
||||
disabled={ !canDeleteSites }
|
||||
onClick={ () => canDeleteSites && this.remove(_site) }
|
||||
>
|
||||
<Icon name="trash" size="16" color="teal" />
|
||||
</button>
|
||||
<button
|
||||
className={cn({'hidden' : !isAdmin})}
|
||||
disabled={ !isAdmin }
|
||||
onClick={ () => isAdmin && this.edit(_site) }
|
||||
data-clickable
|
||||
>
|
||||
<Icon name="edit" size="16" color="teal"/>
|
||||
</button>
|
||||
<div><Button size="small" outline primary onClick={ () => this.showTrackingCode(_site) }>{ 'Tracking Code' }</Button></div>
|
||||
{/* <button disabled={ !isAdmin } onClick={ () => this.showGDPRForm(_site) } ><Icon name="cog" size="16" color="teal" /></button> */}
|
||||
</div>
|
||||
</div>
|
||||
// </div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
81
frontend/app/components/Client/Users/UsersView.tsx
Normal file
81
frontend/app/components/Client/Users/UsersView.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import UserList from './components/UserList';
|
||||
import { PageTitle, Popup, IconButton } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import UserSearch from './components/UserSearch';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import UserForm from './components/UserForm';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const PERMISSION_WARNING = 'You don’t have the permissions to perform this action.';
|
||||
const LIMIT_WARNING = 'You have reached users limit.';
|
||||
interface Props {
|
||||
account: any;
|
||||
isEnterprise: boolean;
|
||||
limits: any;
|
||||
}
|
||||
function UsersView(props: Props) {
|
||||
const { account, limits, isEnterprise } = props;
|
||||
const { userStore, roleStore } = useStore();
|
||||
const userCount = useObserver(() => userStore.list.length);
|
||||
const roles = useObserver(() => roleStore.list);
|
||||
const { showModal } = useModal();
|
||||
|
||||
const reachedLimit = (limits.remaining + userStore.modifiedCount) <= 0;
|
||||
const isAdmin = account.admin || account.superAdmin;
|
||||
|
||||
const editHandler = (user = null) => {
|
||||
userStore.initUser(user).then(() => {
|
||||
showModal(<UserForm />, {});
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length === 0) {
|
||||
roleStore.fetchRoles();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<PageTitle
|
||||
title={<div>Team <span className="color-gray-medium">{userCount}</span></div>}
|
||||
actionButton={(
|
||||
<Popup
|
||||
trigger={
|
||||
<div>
|
||||
<IconButton
|
||||
id="add-button"
|
||||
disabled={ reachedLimit || !isAdmin }
|
||||
circle
|
||||
icon="plus"
|
||||
outline
|
||||
className="ml-3"
|
||||
onClick={ () => editHandler(null) }
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
content={ `${ !isAdmin ? PERMISSION_WARNING : (reachedLimit ? LIMIT_WARNING : 'Add team member') }` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top left"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<UserSearch />
|
||||
</div>
|
||||
</div>
|
||||
<UserList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
account: state.getIn([ 'user', 'account' ]),
|
||||
isEnterprise: state.getIn([ 'user', 'client', 'edition' ]) === 'ee',
|
||||
limits: state.getIn([ 'user', 'account', 'limits', 'teamMember' ]),
|
||||
// remaining: this.props.account.limits.teamMember.remaining
|
||||
}))(UsersView);
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import React from 'react';
|
||||
import { Input, CopyButton, Button, Icon } from 'UI'
|
||||
import cn from 'classnames';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import Select from 'Shared/Select';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
interface Props {
|
||||
isSmtp?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
}
|
||||
function UserForm(props: Props) {
|
||||
const { isSmtp = false, isEnterprise = false } = props;
|
||||
const { hideModal } = useModal();
|
||||
const { userStore, roleStore } = useStore();
|
||||
const isSaving = useObserver(() => userStore.saving);
|
||||
const user: any = useObserver(() => userStore.instance);
|
||||
const roles = useObserver(() => roleStore.list.filter(r => r.isProtected ? user.isSuperAdmin : true).map(r => ({ label: r.name, value: r.roleId })));
|
||||
|
||||
const onChangeCheckbox = (e: any) => {
|
||||
user.updateKey('isAdmin', !user.isAdmin);
|
||||
}
|
||||
|
||||
const onSave = () => {
|
||||
userStore.saveUser(user).then(() => {
|
||||
hideModal();
|
||||
});
|
||||
}
|
||||
|
||||
const write = ({ target: { name, value } }) => {
|
||||
user.updateKey(name, value);
|
||||
}
|
||||
|
||||
const deleteHandler = async () => {
|
||||
if (await confirm({
|
||||
header: 'Confirm',
|
||||
confirmButton: 'Yes, delete',
|
||||
confirmation: `Are you sure you want to permanently delete this user?`
|
||||
})) {
|
||||
userStore.deleteUser(user.userId).then(() => {
|
||||
hideModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return useObserver(() => (
|
||||
<div className="bg-white h-screen p-6" style={{ width: '400px'}}>
|
||||
<div className="">
|
||||
<h1 className="text-2xl mb-4">{`${user.exists() ? 'Update' : 'Invite'} User`}</h1>
|
||||
</div>
|
||||
<form onSubmit={ onSave } >
|
||||
<div className="form-group">
|
||||
<label>{ 'Full Name' }</label>
|
||||
<Input
|
||||
name="name"
|
||||
autoFocus
|
||||
value={ user.name }
|
||||
onChange={ write }
|
||||
className="w-full"
|
||||
id="name-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{ 'Email Address' }</label>
|
||||
<Input
|
||||
disabled={user.exists()}
|
||||
name="email"
|
||||
value={ user.email }
|
||||
onChange={ write }
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{ !isSmtp &&
|
||||
<div className={cn("mb-4 p-2 bg-yellow rounded")}>
|
||||
SMTP is not configured (see <a className="link" href="https://docs.openreplay.com/configuration/configure-smtp" target="_blank">here</a> how to set it up). You can still add new users, but you’d have to manually copy then send them the invitation link.
|
||||
</div>
|
||||
}
|
||||
<div className="form-group">
|
||||
<label className="flex items-start cursor-pointer">
|
||||
<input
|
||||
name="admin"
|
||||
type="checkbox"
|
||||
checked={ !!user.isAdmin || !!user.isSuperAdmin }
|
||||
onChange={ onChangeCheckbox }
|
||||
disabled={user.isSuperAdmin}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="ml-2 select-none">
|
||||
<span>Admin Privileges</span>
|
||||
<div className="text-sm color-gray-medium -mt-1">{ 'Can manage Projects and team members.' }</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{ !isEnterprise && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="role">{ 'Role' }</label>
|
||||
<Select
|
||||
placeholder="Selct Role"
|
||||
selection
|
||||
options={ roles }
|
||||
name="roleId"
|
||||
defaultValue={ user.roleId }
|
||||
onChange={({ value }) => user.updateKey('roleId', value)}
|
||||
className="block"
|
||||
isDisabled={user.isSuperAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center mr-auto">
|
||||
<Button
|
||||
onClick={ onSave }
|
||||
disabled={ !user.valid() || isSaving }
|
||||
loading={ isSaving }
|
||||
primary
|
||||
marginRight
|
||||
>
|
||||
{ user.exists() ? 'Update' : 'Invite' }
|
||||
</Button>
|
||||
<Button
|
||||
data-hidden={ !user.exists() }
|
||||
onClick={ hideModal }
|
||||
outline
|
||||
>
|
||||
{ 'Cancel' }
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
data-hidden={ !user.exists() }
|
||||
onClick={ deleteHandler }
|
||||
>
|
||||
<Icon name="trash" size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ !user.isJoined && user.invitationLink &&
|
||||
<CopyButton
|
||||
content={user.invitationLink}
|
||||
className="link mt-4"
|
||||
btnText="Copy invite link"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
export default UserForm;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './UserForm';
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import React, { useEffect } from 'react';
|
||||
import UserListItem from '../UserListItem';
|
||||
import { sliceListPerPage, getRE } from 'App/utils';
|
||||
import { Pagination, NoContent, Loader } from 'UI';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import UserForm from '../UserForm';
|
||||
|
||||
function UserList(props) {
|
||||
const { userStore } = useStore();
|
||||
const loading = useObserver(() => userStore.loading);
|
||||
const users = useObserver(() => userStore.list);
|
||||
const searchQuery = useObserver(() => userStore.searchQuery);
|
||||
const { showModal } = useModal();
|
||||
|
||||
const filterList = (list) => {
|
||||
const filterRE = getRE(searchQuery, 'i');
|
||||
let _list = list.filter(w => {
|
||||
return filterRE.test(w.email) || filterRE.test(w.roleName);
|
||||
});
|
||||
return _list
|
||||
}
|
||||
|
||||
const list: any = searchQuery !== '' ? filterList(users) : users;
|
||||
const length = list.length;
|
||||
|
||||
useEffect(() => {
|
||||
userStore.fetchUsers();
|
||||
}, []);
|
||||
|
||||
const editHandler = (user) => {
|
||||
userStore.initUser(user).then(() => {
|
||||
showModal(<UserForm />, { });
|
||||
});
|
||||
}
|
||||
|
||||
return useObserver(() => (
|
||||
<Loader loading={loading}>
|
||||
<NoContent show={!loading && length === 0} animatedIcon="empty-state">
|
||||
<div className="mt-3 rounded bg-white">
|
||||
<div className="grid grid-cols-12 p-3 border-b font-medium">
|
||||
<div className="col-span-5">Name</div>
|
||||
<div className="col-span-3">Role</div>
|
||||
<div className="col-span-2">Created On</div>
|
||||
<div className="col-span-2"></div>
|
||||
</div>
|
||||
|
||||
{sliceListPerPage(list, userStore.page - 1, userStore.pageSize).map((user: any) => (
|
||||
<div key={user.id} className="">
|
||||
<UserListItem
|
||||
user={user}
|
||||
editHandler={() => editHandler(user)}
|
||||
generateInvite={() => userStore.generateInviteCode(user.userId)}
|
||||
copyInviteCode={() => userStore.copyInviteCode(user.userId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-center py-10">
|
||||
<Pagination
|
||||
page={userStore.page}
|
||||
totalPages={Math.ceil(length / userStore.pageSize)}
|
||||
onPageChange={(page) => userStore.updateKey('page', page)}
|
||||
limit={userStore.pageSize}
|
||||
debounceRequest={100}
|
||||
/>
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
));
|
||||
}
|
||||
|
||||
export default UserList;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './UserList'
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
//@ts-nocheck
|
||||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { checkForRecent } from 'App/date';
|
||||
import { Tooltip } from 'react-tippy';
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
editHandler?: any;
|
||||
generateInvite?: any;
|
||||
copyInviteCode?: any;
|
||||
}
|
||||
function UserListItem(props: Props) {
|
||||
const {
|
||||
user,
|
||||
editHandler = () => {},
|
||||
generateInvite = () => {},
|
||||
copyInviteCode = () => {},
|
||||
} = props;
|
||||
return (
|
||||
<div className="grid grid-cols-12 p-3 py-4 border-b items-center select-none hover:bg-active-blue group">
|
||||
<div className="col-span-5">
|
||||
{user.name}
|
||||
{user.isAdmin && <span className="ml-2 px-2 py-1 bg-gray-lightest rounded border text-sm capitalize">Admin</span>}
|
||||
{user.isSuperAdmin && <span className="ml-2 px-2 py-1 bg-gray-lightest rounded border text-sm capitalize">Owner</span>}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<span className="px-2 py-1 bg-gray-lightest rounded border text-sm capitalize">
|
||||
{user.roleName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span>{user.createdAt && checkForRecent(user.createdAt, 'LLL dd, yyyy, hh:mm a')}</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 justify-self-end invisible group-hover:visible">
|
||||
<div className="grid grid-cols-2 gap-3 items-center justify-end">
|
||||
{!user.isJoined && user.invitationLink ? (
|
||||
<Tooltip
|
||||
delay={500}
|
||||
arrow
|
||||
title="Copy Invite Code"
|
||||
hideOnClick={true}
|
||||
>
|
||||
<button className='' onClick={copyInviteCode}>
|
||||
<Icon name="link-45deg" size="16" color="teal"/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : <div/>}
|
||||
{!user.isJoined && user.isExpiredInvite && (
|
||||
<Tooltip
|
||||
delay={500}
|
||||
arrow
|
||||
title="Generate Invite"
|
||||
hideOnClick={true}
|
||||
>
|
||||
<button className='' onClick={generateInvite}>
|
||||
<Icon name="link-45deg" size="16" color="red"/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button className='' onClick={editHandler}>
|
||||
<Icon name="pencil" color="teal" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserListItem;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './UserListItem';
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useObserver } from 'mobx-react-lite';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Icon } from 'UI';
|
||||
import { debounce } from 'App/utils';
|
||||
|
||||
let debounceUpdate: any = () => {}
|
||||
function UserSearch(props) {
|
||||
const { userStore } = useStore();
|
||||
const [query, setQuery] = useState(userStore.searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
debounceUpdate = debounce((key, value) => userStore.updateKey(key, value), 500);
|
||||
}, [])
|
||||
|
||||
const write = ({ target: { name, value } }) => {
|
||||
setQuery(value);
|
||||
debounceUpdate(name, value);
|
||||
}
|
||||
|
||||
return useObserver(() => (
|
||||
<div className="relative" style={{ width: '300px'}}>
|
||||
<Icon name="search" className="absolute top-0 bottom-0 ml-3 m-auto" size="16" />
|
||||
<input
|
||||
value={query}
|
||||
name="searchQuery"
|
||||
className="bg-white p-2 border border-gray-light rounded w-full pl-10"
|
||||
placeholder="Filter by Name, Role"
|
||||
onChange={write}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
export default UserSearch;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './UserSearch';
|
||||
|
|
@ -39,7 +39,7 @@ export default function({ plain = false, options, isSearchable = false, defaultV
|
|||
return { ...provided, opacity, transition };
|
||||
}
|
||||
}
|
||||
const defaultSelected = defaultValue ? options.find(x => x.value === defaultValue) : options[0];
|
||||
const defaultSelected = defaultValue ? options.find(x => x.value === defaultValue) : null;
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ function DefaultTimezone(props) {
|
|||
const { settingsStore } = useStore();
|
||||
const [timezone, setTimezone] = React.useState(settingsStore.sessionSettings.timezone);
|
||||
const sessionSettings = useObserver(() => settingsStore.sessionSettings)
|
||||
console.log('timezone', timezone)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
import React from 'react';
|
||||
import DashboardStore, { IDashboardSotre } from './dashboardStore';
|
||||
import MetricStore, { IMetricStore } from './metricStore';
|
||||
import UserStore from './userStore';
|
||||
import RoleStore from './roleStore';
|
||||
import APIClient from 'App/api_client';
|
||||
import { dashboardService, metricService, sessionService } from 'App/services';
|
||||
import { dashboardService, metricService, sessionService, userService } from 'App/services';
|
||||
import SettingsStore from './settingsStore';
|
||||
|
||||
export class RootStore {
|
||||
dashboardStore: IDashboardSotre;
|
||||
metricStore: IMetricStore;
|
||||
settingsStore: SettingsStore;
|
||||
userStore: UserStore;
|
||||
roleStore: RoleStore;
|
||||
|
||||
constructor() {
|
||||
this.dashboardStore = new DashboardStore();
|
||||
this.metricStore = new MetricStore();
|
||||
this.settingsStore = new SettingsStore();
|
||||
this.userStore = new UserStore();
|
||||
this.roleStore = new RoleStore();
|
||||
}
|
||||
|
||||
initClient() {
|
||||
|
|
@ -21,6 +27,7 @@ export class RootStore {
|
|||
dashboardService.initClient(client)
|
||||
metricService.initClient(client)
|
||||
sessionService.initClient(client)
|
||||
userService.initClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
31
frontend/app/mstore/roleStore.ts
Normal file
31
frontend/app/mstore/roleStore.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { makeAutoObservable, observable, action } from "mobx"
|
||||
import { userService } from "App/services";
|
||||
import Role, { IRole } from "./types/role";
|
||||
|
||||
export default class UserStore {
|
||||
list: IRole[] = [];
|
||||
loading: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
list: observable,
|
||||
loading: observable,
|
||||
})
|
||||
}
|
||||
|
||||
fetchRoles(): Promise<any> {
|
||||
this.loading = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
userService.getRoles()
|
||||
.then(response => {
|
||||
this.list = response.map((role: any) => new Role().fromJson(role));
|
||||
resolve(response);
|
||||
}).catch(error => {
|
||||
this.loading = false;
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
45
frontend/app/mstore/types/role.ts
Normal file
45
frontend/app/mstore/types/role.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { makeAutoObservable, observable, runInAction } from "mobx";
|
||||
|
||||
export interface IRole {
|
||||
roleId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isProtected: boolean;
|
||||
|
||||
fromJson(json: any);
|
||||
toJson(): any;
|
||||
}
|
||||
|
||||
export default class Role implements IRole {
|
||||
roleId: string = '';
|
||||
name: string = '';
|
||||
description: string = '';
|
||||
isProtected: boolean = false;
|
||||
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
roleId: observable,
|
||||
name: observable,
|
||||
description: observable,
|
||||
})
|
||||
}
|
||||
|
||||
fromJson(json: any) {
|
||||
runInAction(() => {
|
||||
this.roleId = json.roleId;
|
||||
this.name = json.name;
|
||||
this.description = json.description;
|
||||
this.isProtected = json.protected;
|
||||
})
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
id: this.roleId,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
105
frontend/app/mstore/types/user.ts
Normal file
105
frontend/app/mstore/types/user.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { runInAction, makeAutoObservable, observable } from 'mobx'
|
||||
import { DateTime } from 'luxon';
|
||||
import { validateEmail, validateName } from 'App/validate';
|
||||
|
||||
export interface IUser {
|
||||
userId: string
|
||||
email: string
|
||||
createdAt: string
|
||||
isAdmin: boolean
|
||||
isSuperAdmin: boolean
|
||||
isJoined: boolean
|
||||
isExpiredInvite: boolean
|
||||
roleId: string
|
||||
roleName: string
|
||||
invitationLink: string
|
||||
|
||||
|
||||
updateKey(key: string, value: any): void
|
||||
fromJson(json: any): IUser
|
||||
toJson(): any
|
||||
toSave(): any
|
||||
}
|
||||
|
||||
export default class User implements IUser {
|
||||
userId: string = '';
|
||||
name: string = '';
|
||||
email: string = '';
|
||||
createdAt: string = '';
|
||||
isAdmin: boolean = false;
|
||||
isSuperAdmin: boolean = false;
|
||||
isJoined: boolean = false;
|
||||
isExpiredInvite: boolean = false;
|
||||
roleId: string = '';
|
||||
roleName: string = '';
|
||||
invitationLink: string = '';
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
userId: observable,
|
||||
email: observable,
|
||||
createdAt: observable,
|
||||
isAdmin: observable,
|
||||
isSuperAdmin: observable,
|
||||
isJoined: observable,
|
||||
isExpiredInvite: observable,
|
||||
roleId: observable,
|
||||
roleName: observable,
|
||||
invitationLink: observable,
|
||||
})
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
runInAction(() => {
|
||||
this[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
fromJson(json: any) {
|
||||
runInAction(() => {
|
||||
this.userId = json.userId || json.id; // TODO api returning id
|
||||
this.name = json.name;
|
||||
this.email = json.email;
|
||||
this.createdAt = json.createdAt && DateTime.fromISO(json.createdAt || 0)
|
||||
this.isAdmin = json.admin
|
||||
this.isSuperAdmin = json.superAdmin
|
||||
this.isJoined = json.joined
|
||||
this.isExpiredInvite = json.expiredInvitation
|
||||
this.roleId = json.roleId
|
||||
this.roleName = json.roleName
|
||||
this.invitationLink = json.invitationLink
|
||||
})
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
userId: this.userId,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
admin: this.isAdmin,
|
||||
superAdmin: this.isSuperAdmin,
|
||||
roleId: this.roleId,
|
||||
joined: this.isJoined,
|
||||
invitationLink: this.invitationLink,
|
||||
expiredInvitation: this.isExpiredInvite,
|
||||
}
|
||||
}
|
||||
|
||||
toSave() {
|
||||
return {
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
admin: this.isAdmin,
|
||||
roleId: this.roleId,
|
||||
}
|
||||
}
|
||||
|
||||
valid() {
|
||||
return validateName(this.name, { empty: false }) && validateEmail(this.email) && !!this.roleId;
|
||||
}
|
||||
|
||||
exists() {
|
||||
return !!this.userId;
|
||||
}
|
||||
}
|
||||
162
frontend/app/mstore/userStore.ts
Normal file
162
frontend/app/mstore/userStore.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { makeAutoObservable, observable, action } from "mobx"
|
||||
import User, { IUser } from "./types/user";
|
||||
import { userService } from "App/services";
|
||||
import { toast } from 'react-toastify';
|
||||
import copy from 'copy-to-clipboard';
|
||||
|
||||
export default class UserStore {
|
||||
list: IUser[] = [];
|
||||
instance: IUser|null = null;
|
||||
page: number = 1;
|
||||
pageSize: number = 10;
|
||||
searchQuery: string = "";
|
||||
modifiedCount: number = 0;
|
||||
|
||||
loading: boolean = false;
|
||||
saving: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
instance: observable,
|
||||
updateUser: action,
|
||||
updateKey: action,
|
||||
initUser: action,
|
||||
})
|
||||
}
|
||||
|
||||
initUser(user?: any ): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (user) {
|
||||
this.instance = new User().fromJson(user.toJson());
|
||||
} else {
|
||||
this.instance = new User();
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
this[key] = value
|
||||
|
||||
if (key === 'searchQuery') {
|
||||
this.page = 1
|
||||
}
|
||||
}
|
||||
|
||||
updateUser(user: IUser) {
|
||||
const index = this.list.findIndex(u => u.userId === user.userId);
|
||||
if (index > -1) {
|
||||
this.list[index] = user;
|
||||
}
|
||||
}
|
||||
|
||||
fetchUser(userId: string): Promise<any> {
|
||||
this.loading = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
userService.one(userId)
|
||||
.then(response => {
|
||||
this.instance = new User().fromJson(response.data);
|
||||
resolve(response);
|
||||
}).catch(error => {
|
||||
this.loading = false;
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetchUsers(): Promise<any> {
|
||||
this.loading = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
userService.all()
|
||||
.then(response => {
|
||||
this.list = response.map(user => new User().fromJson(user));
|
||||
resolve(response);
|
||||
}).catch(error => {
|
||||
this.loading = false;
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
saveUser(user: IUser): Promise<any> {
|
||||
this.saving = true;
|
||||
const wasCreating = !user.userId;
|
||||
return new Promise((resolve, reject) => {
|
||||
userService.save(user).then(response => {
|
||||
const newUser = new User().fromJson(response);
|
||||
if (wasCreating) {
|
||||
this.modifiedCount -= 1;
|
||||
this.list.push(new User().fromJson(newUser));
|
||||
toast.success('User created successfully');
|
||||
} else {
|
||||
this.updateUser(newUser);
|
||||
toast.success('User updated successfully');
|
||||
}
|
||||
resolve(response);
|
||||
}).catch(error => {
|
||||
this.saving = false;
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteUser(userId: string): Promise<any> {
|
||||
this.saving = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
userService.delete(userId)
|
||||
.then(response => {
|
||||
this.modifiedCount += 1;
|
||||
this.list = this.list.filter(user => user.userId !== userId);
|
||||
resolve(response);
|
||||
}).catch(error => {
|
||||
this.saving = false;
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
copyInviteCode(userId: string): void {
|
||||
const content = this.list.find(u => u.userId === userId)?.invitationLink;
|
||||
if (content) {
|
||||
copy(content);
|
||||
toast.success('Invite code copied successfully');
|
||||
} else {
|
||||
toast.error('Invite code not found');
|
||||
}
|
||||
}
|
||||
|
||||
generateInviteCode(userId: string): Promise<any> {
|
||||
this.saving = true;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
userService.generateInviteCode(userId)
|
||||
.then(response => {
|
||||
const index = this.list.findIndex(u => u.userId === userId);
|
||||
if (index > -1) {
|
||||
this.list[index].updateKey('isExpiredInvite', false);
|
||||
this.list[index].updateKey('invitationLink', response.invitationLink);
|
||||
}
|
||||
resolve(response);
|
||||
}).catch(error => {
|
||||
this.saving = false;
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
pending: 'Generating an invite code...',
|
||||
success: 'Invite code generated successfully',
|
||||
})
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
57
frontend/app/services/UserService.ts
Normal file
57
frontend/app/services/UserService.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import APIClient from 'App/api_client';
|
||||
import { IUser } from 'App/mstore/types/user'
|
||||
|
||||
export default class UserService {
|
||||
private client: APIClient;
|
||||
|
||||
constructor(client?: APIClient) {
|
||||
this.client = client ? client : new APIClient();
|
||||
}
|
||||
|
||||
initClient(client?: APIClient) {
|
||||
this.client = client || new APIClient();
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.client.get('/client/members')
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
}
|
||||
|
||||
one(userId: string) {
|
||||
return this.client.get('/users/' + userId)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
}
|
||||
|
||||
save(user: IUser): Promise<any> {
|
||||
const data = user.toSave();
|
||||
if (user.userId) {
|
||||
return this.client.put('/client/members/' + user.userId, data)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {})
|
||||
} else {
|
||||
return this.client.post('/client/members', data)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
}
|
||||
}
|
||||
|
||||
generateInviteCode(userId: any): Promise<any> {
|
||||
return this.client.get(`/client/members/${userId}/reset`)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
}
|
||||
|
||||
delete(userId: string) {
|
||||
return this.client.delete('/client/members/' + userId)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
}
|
||||
|
||||
getRoles() {
|
||||
return this.client.get('/client/roles')
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import DashboardService, { IDashboardService } from "./DashboardService";
|
||||
import MetricService, { IMetricService } from "./MetricService";
|
||||
import SessionSerivce from "./SessionService";
|
||||
import UserService from "./UserService";
|
||||
|
||||
export const dashboardService: IDashboardService = new DashboardService();
|
||||
export const metricService: IMetricService = new MetricService();
|
||||
export const sessionService: SessionSerivce = new SessionSerivce();
|
||||
export const sessionService: SessionSerivce = new SessionSerivce();
|
||||
export const userService: UserService = new UserService();
|
||||
|
|
@ -147,13 +147,4 @@
|
|||
height: 100vh;
|
||||
overflow-y: hidden;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
/* .svg-map__location {
|
||||
fill: #EEE !important;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
fill: #fff !important;
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ module.exports = {
|
|||
'height',
|
||||
'inset',
|
||||
'justifyContent',
|
||||
'justifySelf',
|
||||
'letterSpacing',
|
||||
'lineHeight',
|
||||
// 'listStylePosition',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue