diff --git a/src/endpoints/groups.js b/src/endpoints/groups.js index cf4266b90..5180bbf20 100644 --- a/src/endpoints/groups.js +++ b/src/endpoints/groups.js @@ -5,6 +5,7 @@ import {contentTypesProvided, contentTypesConsumed} from "../negotiate.js"; import {deleteByUrls, proxyFromUpstream, proxyToUpstream} from "../upstream.js"; import { slurp } from "../utils/iterators.js"; import * as options from "./options.js"; +import * as nextJsApp from "./nextjs.js"; const setGroup = (nameExtractor) => (req, res, next) => { @@ -156,13 +157,16 @@ async function receiveGroupLogo(req, res) { /* Members and roles */ -const listMembers = async (req, res) => { - const group = req.context.group; - - authz.assertAuthorized(req.user, authz.actions.Read, group); +const listMembers = contentTypesProvided([ + ["html", nextJsApp.handleRequest], + ["json", async (req, res) => { + const group = req.context.group; + authz.assertAuthorized(req.user, authz.actions.Read, group); - return res.json(await group.members()); -}; + return res.json(await group.members()); + } + ], +]); const listRoles = (req, res) => { diff --git a/src/groups.js b/src/groups.js index f9e0b918d..d4dbd0395 100644 --- a/src/groups.js +++ b/src/groups.js @@ -69,7 +69,7 @@ class Group { /** * Map of generic roles a member of this Group can hold to fully-qualified - * authz roles. + * authz roles. The roles should be listed in order from least to most privileged. * * The fully-qualified names are used in authz policies, and a user's authz * roles are stored using membership in AWS Cognito groups of the same diff --git a/src/routing/groups.js b/src/routing/groups.js index dba47a544..98cbf00e8 100644 --- a/src/routing/groups.js +++ b/src/routing/groups.js @@ -71,18 +71,25 @@ export function setup(app) { ; app.routeAsync("/groups/:groupName/settings/members") - .getAsync(endpoints.groups.listMembers); + .getAsync(endpoints.groups.listMembers) + .optionsAsync(optionsGroup) + ; app.routeAsync("/groups/:groupName/settings/roles") - .getAsync(endpoints.groups.listRoles); + .getAsync(endpoints.groups.listRoles) + .optionsAsync(optionsGroup) + ; app.routeAsync("/groups/:groupName/settings/roles/:roleName/members") - .getAsync(endpoints.groups.listRoleMembers); + .getAsync(endpoints.groups.listRoleMembers) + .optionsAsync(optionsGroup) + ; app.routeAsync("/groups/:groupName/settings/roles/:roleName/members/:username") .getAsync(endpoints.groups.getRoleMember) .putAsync(endpoints.groups.putRoleMember) .deleteAsync(endpoints.groups.deleteRoleMember) + .optionsAsync(optionsGroup) ; app.route("/groups/:groupName/settings/*") diff --git a/static-site/pages/groups/[...groupName].jsx b/static-site/pages/groups/[...groupName].jsx index a5acc3320..25056e875 100644 --- a/static-site/pages/groups/[...groupName].jsx +++ b/static-site/pages/groups/[...groupName].jsx @@ -1,12 +1,14 @@ import dynamic from 'next/dynamic' import { useRouter } from 'next/router' const IndividualGroupPage = dynamic(() => import("../../src/sections/individual-group-page"), {ssr: false}) +const GroupMembersPage = dynamic(() => import("../../src/sections/group-members-page.tsx"), {ssr: false}) const GroupSettingsPage = dynamic(() => import("../../src/sections/group-settings-page"), {ssr: false}) /** * NextJS dynamic routing doesn't allow us to set up routes where we have (e.g.) * groups/:groupName/settings -> + * groups/:groupName/settings/members -> * groups/:groupName[/*] -> * so we use some JS routing here to serve the right component */ @@ -14,21 +16,21 @@ const Index = () => { const {query} = useRouter(); if (!query.groupName) return null; // wait until query ready - - - /* nextJs dynamic routing will set the part parts _beyond_ "groups/" as query.groupName */ - if (query.groupName.length===2 && query.groupName[1]==='settings') { - // param location TODO!!! - return + const groupName = query.groupName[0] + const resourcePath = query.groupName.slice(1).join("/") + + switch(resourcePath) { + case 'settings': + return + case 'settings/members': + return + default: + return } - - const resourcePath = query.groupName.slice(1); - - return ( - - ); - } -export default Index; \ No newline at end of file +export default Index; diff --git a/static-site/src/layouts/generic-page.jsx b/static-site/src/layouts/generic-page.jsx index 142be3bfb..8841e95f8 100644 --- a/static-site/src/layouts/generic-page.jsx +++ b/static-site/src/layouts/generic-page.jsx @@ -7,13 +7,13 @@ import UserDataWrapper from "../layouts/userDataWrapper"; import { BigSpacer, HugeSpacer, Line } from "../layouts/generalComponents"; import * as splashStyles from "../components/splash/styles"; -const GenericPage = ({location, children, banner}) => ( +const GenericPage = ({children, banner}) => (
- + {banner} diff --git a/static-site/src/pages/contact.jsx b/static-site/src/pages/contact.jsx index 87050f85d..9819cc171 100644 --- a/static-site/src/pages/contact.jsx +++ b/static-site/src/pages/contact.jsx @@ -4,8 +4,8 @@ import { BigSpacer, FlexCenter} from "../layouts/generalComponents"; import { H1, NarrowFocusParagraph } from "../components/splash/styles"; -const Contact = props => ( - +const Contact = () => ( +

Contact Us

diff --git a/static-site/src/pages/groups.jsx b/static-site/src/pages/groups.jsx index 32e49dd1f..5955d911d 100644 --- a/static-site/src/pages/groups.jsx +++ b/static-site/src/pages/groups.jsx @@ -61,8 +61,8 @@ const GroupListingInfo = () => { // GenericPage needs to be a parent of GroupsPage for the latter to know about // UserContext, specifically for class functions to be able to use `this.context`. // We `export default Index` below. -const Index = props => ( - +const Index = () => ( + ); diff --git a/static-site/src/pages/index.jsx b/static-site/src/pages/index.jsx index 2b95c3a3e..b5e98fd8d 100644 --- a/static-site/src/pages/index.jsx +++ b/static-site/src/pages/index.jsx @@ -23,7 +23,7 @@ class Index extends React.Component {
- +
diff --git a/static-site/src/pages/team.jsx b/static-site/src/pages/team.jsx index afc7ee150..ef50843c1 100644 --- a/static-site/src/pages/team.jsx +++ b/static-site/src/pages/team.jsx @@ -65,11 +65,10 @@ const TeamPage = () => { }; -const Team = props => ( - +const Team = () => ( + ); export default Team; - diff --git a/static-site/src/sections/community-page.jsx b/static-site/src/sections/community-page.jsx index 9fd03eaae..f72cdf826 100644 --- a/static-site/src/sections/community-page.jsx +++ b/static-site/src/sections/community-page.jsx @@ -72,7 +72,7 @@ class Index extends React.Component { render() { const banner = this.banner(); return ( - + {title} diff --git a/static-site/src/sections/community-repo-page.jsx b/static-site/src/sections/community-repo-page.jsx index 51ec068bd..ca3111ea4 100644 --- a/static-site/src/sections/community-repo-page.jsx +++ b/static-site/src/sections/community-repo-page.jsx @@ -85,22 +85,21 @@ class Index extends React.Component { } render() { - const location = this.props.location; const banner = this.banner(); if (this.state.repoNotFound) { return ( - + ); } if (!this.state.sourceInfo) { return ( - + Data loading... ); } return ( - + {this.state.sourceInfo.showDatasets && ( diff --git a/static-site/src/sections/group-members-page.tsx b/static-site/src/sections/group-members-page.tsx new file mode 100644 index 000000000..9676510ee --- /dev/null +++ b/static-site/src/sections/group-members-page.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import { startCase } from "lodash" +import { uri } from "../../../src/templateLiterals.js"; +import GenericPage from "../layouts/generic-page.jsx"; +import { BigSpacer, CenteredContainer, FlexGridRight, MediumSpacer } from "../layouts/generalComponents.jsx"; +import * as splashStyles from "../components/splash/styles"; +import { ErrorBanner } from "../components/errorMessages.jsx"; + +interface GroupMember { + username: string, + roles: string[] +} + +interface ErrorMessage { + title: string, + contents: string +} + +const GroupMembersPage = ({ groupName }: {groupName: string}) => { + const [ errorMessage, setErrorMessage ] = useState({title: "", contents: ""}); + const [ roles, setRoles ] = useState([]); + const [ members, setMembers ] = useState([]); + + useEffect(() => { + async function getGroupMembership(groupName: string) { + const headers = { headers: {"Accept": "application/json"}}; + let roles, members = []; + try { + const [ rolesResponse, membersResponse ] = await Promise.all([ + fetch(uri`/groups/${groupName}/settings/roles`, headers), + fetch(uri`/groups/${groupName}/settings/members`, headers) + ]); + if (!rolesResponse.ok) { + throw new Error(`Fetching group roles failed: ${rolesResponse.status} ${rolesResponse.statusText}`) + } + if (!membersResponse.ok) { + throw new Error(`Fetching group members failed: ${membersResponse.status} ${membersResponse.statusText}`) + } + roles = await rolesResponse.json(); + members = await membersResponse.json(); + } catch (err) { + const errorMessage = (err as Error).message + if(!ignore) { + setErrorMessage({ + title: "An error occurred when trying to fetch group membership data", + contents: errorMessage}) + } + } + return {roles, members}; + } + + let ignore = false; + getGroupMembership(groupName).then(result => { + if (!ignore) { + setRoles(result.roles); + setMembers(result.members); + } + }) + return () => { + ignore = true; + }; + }, [groupName]); + + return ( + : undefined}> + + + Return to {`"${groupName}"`} Page + + + + + + {`"${groupName}"`} Group Members + + + + {roles && members + ? + : Fetching group members...} + + ) +}; + +const MembersTableContainer = styled.div` + border: 1px solid #CCC; + .row { + border-bottom: 1px solid #CCC; + } + .row:last-child { + border-bottom: 0; + } + .row:nth-child(even) { + background-color: #F1F1F1; + } + p { + margin: 10px; + } +`; + +const MembersTable = ({ members }: { members: GroupMember[]}) => { + const sortedMembers = members.toSorted((a, b) => a.username.localeCompare(b.username)); + function prettifyRoles(memberRoles: string[]) { + // Prettify the role names by making them singular and capitalized + return memberRoles.map((roleName) => startCase(roleName.replace(/s$/, ''))).join(", "); + } + + return ( + + +
+
+ + Username + +
+
+ + Roles + +
+
+ + {sortedMembers.map((member) => +
+
+ + {member.username} + +
+
+ + {prettifyRoles(member.roles)} + +
+
+ )} +
+
+ ) +}; + +export async function canViewGroupMembers(groupName: string) { + try { + const groupMemberOptions = await fetch(uri`/groups/${groupName}/settings/members`, { method: "OPTIONS"}); + if ([401, 403].includes(groupMemberOptions.status)) { + console.log("You can ignore the console error above; it is used to determine whether the members can be shown."); + } + const allowedMethods = new Set(groupMemberOptions.headers.get("Allow")?.split(/\s*,\s*/)); + return allowedMethods.has("GET"); + } catch (err) { + const errorMessage = (err as Error).message + console.error("Cannot check user permissions to view group members", errorMessage); + } + return false +} + +export default GroupMembersPage; diff --git a/static-site/src/sections/group-settings-page.jsx b/static-site/src/sections/group-settings-page.jsx index eb351260e..a119628a0 100644 --- a/static-site/src/sections/group-settings-page.jsx +++ b/static-site/src/sections/group-settings-page.jsx @@ -12,7 +12,7 @@ const UNAUTHORIZED_MESSAGE = <> If your permissions have changed recently, try logging out and logging back in.
; -const EditGroupSettingsPage = ({ location, groupName }) => { +const EditGroupSettingsPage = ({ groupName }) => { const [ userAuthorized, setUserAuthorized ] = useState(null); const [ errorMessage, setErrorMessage ] = useState(); @@ -39,7 +39,7 @@ const EditGroupSettingsPage = ({ location, groupName }) => { }; return ( - + Return to {`"${groupName}"`} Page diff --git a/static-site/src/sections/individual-group-page.jsx b/static-site/src/sections/individual-group-page.jsx index a0562204e..3e41489ab 100644 --- a/static-site/src/sections/individual-group-page.jsx +++ b/static-site/src/sections/individual-group-page.jsx @@ -9,6 +9,7 @@ import { fetchAndParseJSON } from "../util/datasetsHelpers"; import SourceInfoHeading from "../components/sourceInfoHeading"; import { ErrorBanner } from "../components/errorMessages"; import { canUserEditGroupSettings } from "./group-settings-page"; +import { canViewGroupMembers } from "./group-members-page"; class Index extends React.Component { constructor(props) { @@ -16,8 +17,9 @@ class Index extends React.Component { configureAnchors({ offset: -10 }); this.state = { groupNotFound: false, - nonExistentPath: this.props.resourcePath?.join("/"), - editGroupSettingsAllowed: false + nonExistentPath: this.props.resourcePath, + editGroupSettingsAllowed: false, + viewGroupMembersAllowed: false, }; } @@ -44,6 +46,7 @@ class Index extends React.Component { sourceInfo, groupName, editGroupSettingsAllowed: await canUserEditGroupSettings(groupName), + viewGroupMembersAllowed: await canViewGroupMembers(groupName), datasets: this.createDatasetListing(availableData.datasets, groupName), narratives: this.createDatasetListing(availableData.narratives, groupName), }); @@ -88,29 +91,38 @@ class Index extends React.Component { } render() { - const location = this.props.location; const banner = this.banner(); if (this.state.groupNotFound) { return ( - + ); } if (!this.state.sourceInfo) { return ( - + Data loading... ); } return ( - - {this.state.editGroupSettingsAllowed && ( - - - Edit Group Settings - - - )} + + + {this.state.viewGroupMembersAllowed && ( +
+ + Group Members + +
+ )} + {this.state.editGroupSettingsAllowed && ( +
+ + Edit Group Settings + +
+ )} +
+ {this.state.sourceInfo.showDatasets && ( diff --git a/static-site/src/sections/pathogens.jsx b/static-site/src/sections/pathogens.jsx index e610077a2..3513c5b20 100644 --- a/static-site/src/sections/pathogens.jsx +++ b/static-site/src/sections/pathogens.jsx @@ -36,7 +36,7 @@ const resourceListingCallback = async () => { class Index extends React.Component { render() { return ( - + {title} diff --git a/static-site/src/sections/sars-cov-2-forecasts-page.jsx b/static-site/src/sections/sars-cov-2-forecasts-page.jsx index 41aa4cfa5..3624481ab 100644 --- a/static-site/src/sections/sars-cov-2-forecasts-page.jsx +++ b/static-site/src/sections/sars-cov-2-forecasts-page.jsx @@ -46,9 +46,9 @@ const acknowledgement = ( ) -function Index(props) { +function Index() { return ( - + {title} diff --git a/static-site/src/sections/sars-cov-2-page.jsx b/static-site/src/sections/sars-cov-2-page.jsx index dbce7a97d..08dcc7dc2 100644 --- a/static-site/src/sections/sars-cov-2-page.jsx +++ b/static-site/src/sections/sars-cov-2-page.jsx @@ -188,7 +188,7 @@ class Index extends React.Component { render() { const banner = this.banner(); return ( - + {title} diff --git a/static-site/src/sections/staging-page.jsx b/static-site/src/sections/staging-page.jsx index 6b245729b..5868c6f7e 100644 --- a/static-site/src/sections/staging-page.jsx +++ b/static-site/src/sections/staging-page.jsx @@ -63,7 +63,7 @@ class Index extends React.Component { render() { const banner = this.banner(); return ( - + {title} diff --git a/static-site/src/templates/displayMarkdown.jsx b/static-site/src/templates/displayMarkdown.jsx index 0af609bd1..2f4edc79f 100644 --- a/static-site/src/templates/displayMarkdown.jsx +++ b/static-site/src/templates/displayMarkdown.jsx @@ -72,7 +72,7 @@ export default class GenericTemplate extends React.Component { - +