diff --git a/scripts/seed.js b/scripts/seed.js
index 12875f1..374bc6c 100644
--- a/scripts/seed.js
+++ b/scripts/seed.js
@@ -44,15 +44,18 @@ const N_USERS = 50;
const users = [];
console.log('Registering users...');
for (let i = 0; i < N_USERS; i++) {
- const action = await db.post({
- '@type': 'RegisterAction',
- actionStatus: 'CompletedActionStatus',
- agent: {
- '@type': 'Person',
- orcid: createRandomOrcid(),
- name: faker.name.findName()
- }
- });
+ const action = await db.post(
+ {
+ '@type': 'RegisterAction',
+ actionStatus: 'CompletedActionStatus',
+ agent: {
+ '@type': 'Person',
+ orcid: createRandomOrcid(),
+ name: faker.name.findName()
+ }
+ },
+ { isModerator: true }
+ );
console.log(`\t- ${action.result.name} (${action.result.orcid})`);
let user = action.result;
diff --git a/src/components/admin-panel.css b/src/components/admin-panel.css
new file mode 100644
index 0000000..5af5d56
--- /dev/null
+++ b/src/components/admin-panel.css
@@ -0,0 +1,19 @@
+.admin-panel {
+ padding: calc(var(--header-bar-height) + var(--lgrd)) var(--mgrd) var(--lgrd);
+ max-width: 900px;
+ min-height: 100vh;
+ margin: 0 auto;
+
+ & .admin-panel__header {
+ font: var(--ui-header-font);
+ border-bottom: 1px solid var(--ruling-color);
+ padding-bottom: var(--sgrd);
+ margin-bottom: var(--lgrd);
+ display: flex;
+ justify-content: space-between;
+ }
+ & .admin-panel__card-list {
+ list-style: none;
+ padding: 0;
+ }
+}
diff --git a/src/components/admin-panel.js b/src/components/admin-panel.js
new file mode 100644
index 0000000..7bcdb01
--- /dev/null
+++ b/src/components/admin-panel.js
@@ -0,0 +1,92 @@
+import React, { useState, useEffect } from 'react';
+import { Helmet } from 'react-helmet-async';
+import { MdChevronRight, MdFirstPage, MdClose } from 'react-icons/md';
+import { useUser } from '../contexts/user-context';
+import { getId, unprefix } from '../utils/jsonld';
+import HeaderBar from './header-bar';
+import { ORG } from '../constants';
+import { createModeratorQs } from '../utils/search';
+import { useRolesSearchResults } from '../hooks/api-hooks';
+import Button from './button';
+import IconButton from './icon-button';
+import { RoleBadgeUI } from './role-badge';
+
+export default function AdminPanel() {
+ const [user] = useUser();
+ const [bookmark, setBookmark] = useState(null);
+
+ const search = createModeratorQs({ bookmark });
+
+ const [results, progress] = useRolesSearchResults(search);
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, []);
+
+ return (
+
+
+ {ORG} • Admin panel
+
+
+
+
+
+
+ {results.total_rows === 0 && !progress.isActive ? (
+ No moderators.
+ ) : (
+
+
Add moderator
+
+
+ {results.rows.map(({ doc: role }) => (
+
+
+ {role.name || unprefix(getId(role))}
+
+ {role['@type'] === 'AnonymousReviewerRole'
+ ? 'Anonymous'
+ : 'Public'}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Cloudant returns the same bookmark when it hits the end of the list */}
+ {!!bookmark && (
+ {
+ setBookmark(null);
+ }}
+ >
+ First page
+
+ )}
+
+ {!!(
+ results.rows.length < results.total_rows &&
+ results.bookmark !== bookmark
+ ) && (
+ {
+ setBookmark(results.bookmark);
+ }}
+ >
+ Next Page
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/api.js b/src/components/api.js
index 0a76b66..5290b75 100644
--- a/src/components/api.js
+++ b/src/components/api.js
@@ -131,6 +131,34 @@ export default function API() {
+
+
+ isModerated
+
+
+ A boolean indicating that the role has been moderated following
+ repeated violations of the{' '}
+
+ Code of Conduct
+
+
+
+ Boolean (one of true
, false
)
+
+
+
+
+
+ isModerator
+
+
+ A boolean indicating that the role has moderator permissions
+
+
+ Boolean (one of true
, false
)
+
+
+
isRoleOfId
diff --git a/src/components/app.js b/src/components/app.js
index 7ee0bb3..056d4dc 100644
--- a/src/components/app.js
+++ b/src/components/app.js
@@ -14,7 +14,7 @@ import { StoresProvider } from '../contexts/store-context';
import Login from './login';
import Settings from './settings';
import Profile from './profile';
-import PrivateRoute from './private-route';
+import PrivateRoute, { AdminRoute } from './private-route';
import ModeratorRoute from './moderator-route';
import ExtensionSplash from './extension-splash';
import ToCPage from './toc-page';
@@ -22,6 +22,7 @@ import CodeOfConduct from './code-of-conduct';
import NotFound from './not-found';
import API from './api';
import Moderate from './moderate';
+import AdminPanel from './admin-panel';
// kick off the polyfill!
smoothscroll.polyfill();
@@ -64,6 +65,9 @@ export default function App({ user }) {
+
+
+
diff --git a/src/components/header-bar.js b/src/components/header-bar.js
index 7b32d83..44da371 100644
--- a/src/components/header-bar.js
+++ b/src/components/header-bar.js
@@ -55,8 +55,24 @@ export default function HeaderBar({ onClickMenuButton }) {
}
target={process.env.IS_EXTENSION ? '_blank' : undefined}
>
- Settings
+ Profile Settings
+
+ {user.isAdmin && (
+
+ Admin Settings
+
+ )}
+
{!!(role && role.isModerator && !role.isModerated) && (
that redirects to the login
// screen if you're not yet authenticated.
@@ -14,3 +15,23 @@ export default function PrivateRoute({ children, ...rest }) {
PrivateRoute.propTypes = {
children: PropTypes.any.isRequired
};
+
+export function AdminRoute({ children, ...rest }) {
+ const [user] = useUser();
+
+ return (
+
+ {user && user.isAdmin ? (
+ children
+ ) : user && !user.isAdmin ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+AdminRoute.propTypes = {
+ children: PropTypes.any.isRequired
+};
diff --git a/src/ddocs/ddoc-docs.js b/src/ddocs/ddoc-docs.js
index 72e98ab..0852d03 100644
--- a/src/ddocs/ddoc-docs.js
+++ b/src/ddocs/ddoc-docs.js
@@ -133,6 +133,9 @@ const ddoc = {
index('isRoleOfId', doc.isRoleOf);
}
+ index('isModerated', !!doc.isModerated, { facet: true });
+ index('isModerator', !!doc.isModerator, { facet: true });
+
var startDate = doc.startDate
? new Date(doc.startDate).getTime()
: new Date('0000').getTime();
diff --git a/src/hooks/api-hooks.js b/src/hooks/api-hooks.js
index 01a9ff5..026e4c9 100644
--- a/src/hooks/api-hooks.js
+++ b/src/hooks/api-hooks.js
@@ -685,3 +685,73 @@ export function useActionsSearchResults(
return [results, progress, append];
}
+
+/**
+ * Search using the `roles` index
+ */
+export function useRolesSearchResults(
+ search, // the ?qs part of the url
+ append = false
+) {
+ const [progress, setProgress] = useState({
+ isActive: false,
+ error: null
+ });
+
+ const [results, setResults] = useState(DEFAULT_SEARCH_RESULTS);
+
+ useEffect(() => {
+ setProgress({
+ isActive: true,
+ error: null
+ });
+ if (!append) {
+ setResults(DEFAULT_SEARCH_RESULTS);
+ }
+
+ const controller = new AbortController();
+
+ fetch(`${process.env.API_URL}/api/role/${search}`, {
+ signal: controller.signal
+ })
+ .then(resp => {
+ if (resp.ok) {
+ return resp.json();
+ } else {
+ return resp.json().then(
+ body => {
+ throw createError(resp.status, body.description || body.name);
+ },
+ err => {
+ throw createError(resp.status, 'something went wrong');
+ }
+ );
+ }
+ })
+ .then(data => {
+ if (append) {
+ setResults(prevResults => {
+ return Object.assign(data, {
+ rows: prevResults.rows.concat(data.rows)
+ });
+ });
+ } else {
+ setResults(data);
+ }
+
+ setProgress({ isActive: false, error: null });
+ })
+ .catch(err => {
+ if (err.name !== 'AbortError') {
+ setProgress({ isActive: false, error: err });
+ setResults(DEFAULT_SEARCH_RESULTS);
+ }
+ });
+
+ return () => {
+ controller.abort();
+ };
+ }, [search, append]);
+
+ return [results, progress, append];
+}
diff --git a/src/index.css b/src/index.css
index e423502..7b66ea5 100644
--- a/src/index.css
+++ b/src/index.css
@@ -9,6 +9,7 @@
@import './components/variables.css';
@import './components/app.css';
+@import './components/admin-panel.css';
@import './components/add-button.css';
@import './components/api.css';
@import './components/api-section.css';
diff --git a/src/utils/search.js b/src/utils/search.js
index d1cec1b..93a91af 100644
--- a/src/utils/search.js
+++ b/src/utils/search.js
@@ -229,6 +229,26 @@ export function createModerationQs({ bookmark }) {
return sapi ? `?${sapi}` : undefined;
}
+export function createModeratorQs({ bookmark }) {
+ const api = new URLSearchParams();
+
+ api.set('q', `isModerator:true`);
+
+ if (bookmark) {
+ api.set('bookmark', bookmark);
+ } else {
+ api.delete('bookmark');
+ }
+
+ api.set('sort', JSON.stringify(['-startDate']));
+ api.set('include_docs', true);
+ api.set('limit', 10);
+
+ const sapi = api.toString();
+
+ return sapi ? `?${sapi}` : undefined;
+}
+
function escapeLucene(term) {
return term.replace(/([+&|!(){}[\]^"~*?:\\\/-])/g, '\\$1');
}