Merge pull request #447 from openreplay/user-list

UI Improvements - User, Projects
This commit is contained in:
Shekar Siri 2022-05-05 16:32:20 +02:00 committed by GitHub
commit 17aec98298
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 951 additions and 59 deletions

View file

@ -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 } />

View file

@ -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." }

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './SiteSearch';

View file

@ -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>

View 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 dont 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);

View file

@ -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 youd 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;

View file

@ -0,0 +1 @@
export { default } from './UserForm';

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './UserList'

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './UserListItem';

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './UserSearch';

View file

@ -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}

View file

@ -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 (
<>

View file

@ -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)
}
}

View 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;
});
});
}
}

View 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,
}
}
}

View 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;
}
}

View 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;
}
}

View 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 || []);
}
}

View file

@ -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();

View file

@ -147,13 +147,4 @@
height: 100vh;
overflow-y: hidden;
padding-right: 15px;
}
/* .svg-map__location {
fill: #EEE !important;
cursor: pointer;
&:hover {
fill: #fff !important;
}
} */
}

View file

@ -55,6 +55,7 @@ module.exports = {
'height',
'inset',
'justifyContent',
'justifySelf',
'letterSpacing',
'lineHeight',
// 'listStylePosition',