From e9309b102b0e3e45d55a02fc094a38b1ee5102a6 Mon Sep 17 00:00:00 2001 From: the-good-boy Date: Tue, 13 Jun 2023 17:36:06 +0530 Subject: [PATCH 1/6] feat(Admin System): Edit privileges --- .../components/pages/admin-panel-search.tsx | 166 ++++++++++++++++++ .../pages/parts/admin-panel-search-field.tsx | 128 ++++++++++++++ .../parts/admin-panel-search-results.tsx | 157 +++++++++++++++++ .../pages/parts/privilege-badges.js | 46 +++++ .../pages/parts/privs-edit-modal.js | 149 ++++++++++++++++ src/client/containers/layout.js | 23 ++- src/client/controllers/admin-panel.js | 51 ++++++ src/common/helpers/privileges-utils.ts | 82 +++++++++ src/server/routes.js | 2 + src/server/routes/adminPanel.js | 107 +++++++++++ src/server/routes/editor.js | 20 +++ .../icons/shield-check-orange-filled.svg | 21 +++ static/images/icons/shield-grey-center.svg | 18 ++ static/images/icons/shield-orange-center.svg | 18 ++ static/images/icons/shield-white-center.svg | 18 ++ webpack.client.js | 1 + 16 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/client/components/pages/admin-panel-search.tsx create mode 100644 src/client/components/pages/parts/admin-panel-search-field.tsx create mode 100644 src/client/components/pages/parts/admin-panel-search-results.tsx create mode 100644 src/client/components/pages/parts/privilege-badges.js create mode 100644 src/client/components/pages/parts/privs-edit-modal.js create mode 100644 src/client/controllers/admin-panel.js create mode 100644 src/common/helpers/privileges-utils.ts create mode 100644 src/server/routes/adminPanel.js create mode 100644 static/images/icons/shield-check-orange-filled.svg create mode 100644 static/images/icons/shield-grey-center.svg create mode 100644 static/images/icons/shield-orange-center.svg create mode 100644 static/images/icons/shield-white-center.svg diff --git a/src/client/components/pages/admin-panel-search.tsx b/src/client/components/pages/admin-panel-search.tsx new file mode 100644 index 0000000000..5634b8005a --- /dev/null +++ b/src/client/components/pages/admin-panel-search.tsx @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2023 Shivam Awasthi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as React from 'react'; +import AdminPanelSearchField from './parts/admin-panel-search-field'; +import AdminPanelSearchResults from './parts/admin-panel-search-results'; +import {Card} from 'react-bootstrap'; +import PagerElement from './parts/pager'; +import PropTypes from 'prop-types'; + +type Props = { + from?: number, + initialResults?: any[], + nextEnabled: boolean, + query?: string, + resultsPerPage?: number, + user: Record +}; + +type State = { + query: string | null | undefined; + results: any[]; +}; + +class AdminPanelSearchPage extends React.Component { + static displayName = 'AdminPanelSearchPage'; + + static propTypes = { + from: PropTypes.number, + initialResults: PropTypes.array, + nextEnabled: PropTypes.bool.isRequired, + query: PropTypes.string, + resultsPerPage: PropTypes.number, + user: PropTypes.object.isRequired + }; + + static defaultProps = { + from: 0, + initialResults: [], + query: '', + resultsPerPage: 20 + }; + + /** + * Initializes component state to default values and binds class + * methods to proper context so that they can be directly invoked + * without explicit binding. + * + * @param {object} props - Properties object passed down from parents. + */ + constructor(props) { + super(props); + + this.state = { + query: props.query, + results: props.initialResults + }; + + this.paginationUrl = './admin-panel/search'; + } + + paginationUrl: string; + + /** + * Gets user text query from the browser's URL search parameters and + * sets it in the state to be passed down to AdminPanelSearchField and Pager components + * + * @param {string} query - Query string entered by user. + */ + handleSearch = (query: string) => { + this.setState({query}); + }; + + /** + * The Pager component deals with fetching the query from the server. + * We use this callback to set the results on this component's state. + * + * @param {array} newResults - The array of results from the query + */ + searchResultsCallback = (newResults: any[]) => { + this.setState({results: newResults}); + }; + + /** + * The Pager component is set up to react to browser history navigation (prev/next buttons), + * and we use this callback to set the query and type on this component's state. + * + * @param {URLSearchParams} searchParams - The URL search parameters passed up from the pager component + */ + searchParamsChangeCallback = (searchParams: URLSearchParams) => { + let query; + if (searchParams.has('q')) { + query = searchParams.get('q'); + } + if (query === this.state.query) { + return; + } + this.handleSearch(query); + }; + + /** + * Renders the component: Search bar with results table located vertically + * below it. + * + * @returns {object} - JSX to render. + */ + render() { + const {query, results} = this.state; + const querySearchParams = `q=${query}&type=editor`; + return ( + + + Admin Panel + + +
+ + + +
+ {results.length === 0 && query.length !== 0 && +
+
+

+ No results found +

+
} +
+
+
+
+ ); + } +} + +export default AdminPanelSearchPage; diff --git a/src/client/components/pages/parts/admin-panel-search-field.tsx b/src/client/components/pages/parts/admin-panel-search-field.tsx new file mode 100644 index 0000000000..972c11c6b5 --- /dev/null +++ b/src/client/components/pages/parts/admin-panel-search-field.tsx @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2023 Shivam Awasthi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as React from 'react'; +import * as bootstrap from 'react-bootstrap'; + +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import {faSearch} from '@fortawesome/free-solid-svg-icons'; + +const {Button, Col, InputGroup, Form, Row} = bootstrap; + +const SearchButton = ( + +); + +const updateDelay = 1000; + +type AdminPanelSearchFieldState = { + query: string +}; +type AdminPanelSearchFieldProps = { + onSearch: (query: string) => void, + query?: string +}; + +class AdminPanelSearchField extends React.Component { + static displayName = 'AdminPanelSearchField'; + + static propTypes = { + onSearch: PropTypes.func.isRequired, + query: PropTypes.string + }; + + static defaultProps = { + query: '' + }; + + constructor(props: AdminPanelSearchFieldProps) { + super(props); + + this.state = { + query: props.query || '' + }; + this.debouncedTriggerOnSearch = _.debounce(this.triggerOnSearch, updateDelay, {}); + } + + // If search term is changed outside this component (for example browser navigation), + // reflects those changes + componentDidUpdate(prevProps: AdminPanelSearchFieldProps) { + if (prevProps.query !== this.props.query) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({query: this.props.query}); + } + } + + debouncedTriggerOnSearch: () => void; + + triggerOnSearch() { + const {query} = this.state; + this.props.onSearch(query); + } + + handleSubmit = event => { + event.preventDefault(); + event.stopPropagation(); + this.triggerOnSearch(); + }; + + handleChange = event => { + if (!event.target.value.match(/^ +$/) && event.target.value !== this.state.query) { + this.setState({query: event.target.value}, this.debouncedTriggerOnSearch); + } + }; + + render() { + return ( + + +
+ + + + + {SearchButton} + + + +
+ +
+ ); + } +} + +export default AdminPanelSearchField; diff --git a/src/client/components/pages/parts/admin-panel-search-results.tsx b/src/client/components/pages/parts/admin-panel-search-results.tsx new file mode 100644 index 0000000000..37f4eeade5 --- /dev/null +++ b/src/client/components/pages/parts/admin-panel-search-results.tsx @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2023 Shivam Awasthi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as bootstrap from 'react-bootstrap'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import PrivilegeBadges from './privilege-badges'; +import PrivsEditModal from './privs-edit-modal'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {faPencilAlt} from '@fortawesome/free-solid-svg-icons'; +import {genEntityIconHTMLElement} from '../../../helpers/entity'; +import {getPrivilegeShieldIcon} from '../../../../common/helpers/privileges-utils'; + +const {Badge, Button, Table} = bootstrap; + +type AdminPanelSearchResultsState = { + selectedUser?: Record, + showModal: boolean +}; +type AdminPanelSearchResultsProps = { + results?: any[], + user: Record +}; + + +/** + * Renders the document and displays the 'AdminPanelSearchResults' page. + * @returns {ReactElement} a HTML document which displays the AdminPanelSearchResults. + * @param {object} props - Properties passed to the component. + */ +class AdminPanelSearchResults extends React.Component { + static displayName = 'AdminPanelSearchResults'; + + static propTypes = { + results: PropTypes.array, + user: PropTypes.object.isRequired + }; + + static defaultProps = { + results: null + }; + + constructor(props) { + super(props); + + this.state = { + selectedUser: null, + showModal: false + }; + this.onCloseModal = this.onCloseModal.bind(this); + } + + onCloseModal() { + this.setState({showModal: false}); + } + + openPrivsEditModal(user) { + this.setState({ + selectedUser: user, + showModal: true + }); + } + + render() { + const noResults = !this.props.results || this.props.results.length === 0; + + const results = this.props.results.map((result) => { + if (!result) { + return null; + } + const name = result.defaultAlias ? result.defaultAlias.name : + '(unnamed)'; + const link = `/editor/${result.bbid}`; + + /* eslint-disable react/jsx-no-bind */ + return ( + + + + {genEntityIconHTMLElement('editor')} + {name} + + + + + + + + + + ); + }); + let tableCssClasses = 'table table-striped'; + if (noResults) { + return null; + } + return ( +
+ { + this.state.showModal && + ( +
+ +
+ ) + } +

+ Search Results +

+
+ + + + + + + + + {results} + +
NamePrivileges +
+
+ ); + } +} + +export default AdminPanelSearchResults; diff --git a/src/client/components/pages/parts/privilege-badges.js b/src/client/components/pages/parts/privilege-badges.js new file mode 100644 index 0000000000..3acc80a2db --- /dev/null +++ b/src/client/components/pages/parts/privilege-badges.js @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 Shivam Awasthi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import {getBadgeVariantFromTitle, getPrivilegeTitlesArray} from '../../../../common/helpers/privileges-utils'; +import {Badge} from 'react-bootstrap'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import PropTypes from 'prop-types'; +import React from 'react'; + + +function PrivilegeBadges({privs}) { + const privTitles = getPrivilegeTitlesArray(privs); + const privilegeListComp = privTitles.map(title => ( + + + {title} + + {' '} + + )); + return ( +
{privilegeListComp}
+ ); +} + +PrivilegeBadges.displayName = 'PrivilegeBadges'; +PrivilegeBadges.propTypes = { + privs: PropTypes.number.isRequired +}; + +export default PrivilegeBadges; diff --git a/src/client/components/pages/parts/privs-edit-modal.js b/src/client/components/pages/parts/privs-edit-modal.js new file mode 100644 index 0000000000..41003040a0 --- /dev/null +++ b/src/client/components/pages/parts/privs-edit-modal.js @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2023 Shivam Awasthi + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as bootstrap from 'react-bootstrap'; +import {PrivilegeTypeBits, getPrivilegeShieldIcon, getPrivilegeTitleFromBit} from '../../../../common/helpers/privileges-utils'; +import {faPencilAlt, faTimes} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import PrivilegeBadges from './privilege-badges'; +import PropTypes from 'prop-types'; +import React from 'react'; + +const {Button, Form, Modal} = bootstrap; + +class PrivsEditModal extends React.Component { + constructor(props) { + super(props); + this.state = { + privs: props.targetUser.privs ? props.targetUser.privs : null, + submittable: false + }; + + this.handleBitChange = this.handleBitChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + async handleSubmit() { + const {privs} = this.state; + const oldPrivs = this.props.targetUser.privs; + if (privs === oldPrivs) { + return; + } + + const data = { + adminId: this.props.adminId, + newPrivs: privs, + oldPrivs, + targetUserId: this.props.targetUser.id + }; + + try { + const response = await fetch('/editor/privs/edit/handler', { + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + method: 'POST' + }); + if (!response.ok) { + const {error} = await response.json(); + throw new Error(error ?? response.statusText); + } + window.location.reload(); + } + catch (err) { + throw new Error(err); + } + } + + /* eslint-disable no-bitwise */ + handleBitChange(bit) { + const newPrivs = this.state.privs ^ (1 << bit); + if (this.props.targetUser.privs !== newPrivs) { + this.setState({ + privs: newPrivs, + submittable: true + }); + } + else { + this.setState({ + privs: newPrivs, + submittable: false + }); + } + } + + /* eslint-disable react/jsx-no-bind */ + render() { + const link = `/editor/${this.props.targetUser.bbid}`; + + const switches = Object.values(PrivilegeTypeBits).map(bit => ( + this.handleBitChange(bit)} + /> + )); + + return ( + + + + {' '} + + {this.props.targetUser.defaultAlias.name} + + + + + +
+
+ {switches} +
+
+ + + + +
+ ); + } +} + + +PrivsEditModal.displayName = 'PrivsEditModal'; +PrivsEditModal.propTypes = { + adminId: PropTypes.number.isRequired, + handleCloseModal: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + targetUser: PropTypes.object.isRequired +}; + +export default PrivsEditModal; diff --git a/src/client/containers/layout.js b/src/client/containers/layout.js index b727d8e16c..7cd5205169 100644 --- a/src/client/containers/layout.js +++ b/src/client/containers/layout.js @@ -24,7 +24,7 @@ import * as bootstrap from 'react-bootstrap'; import { faChartLine, faGripVertical, faLink, faListUl, faPlus, faQuestionCircle, - faSearch, faSignInAlt, faSignOutAlt, faTrophy, faUserCircle + faSearch, faShieldHalved, faSignInAlt, faSignOutAlt, faTrophy, faUserCircle, faUserGear } from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import Footer from './../components/footer'; @@ -123,12 +123,33 @@ class Layout extends React.Component { ); + const privilegesDropdownTitle = ( + + + {' Privileges'} + + ); + const disableSignUp = this.props.disableSignUp ? {disabled: true} : {}; return (