Skip to content

Commit

Permalink
Merge pull request #973 from nextstrain/list-group-members
Browse files Browse the repository at this point in the history
Add page for listing Group members
  • Loading branch information
joverlee521 committed Aug 15, 2024
2 parents 1e7fe98 + 027910d commit abbe91c
Show file tree
Hide file tree
Showing 19 changed files with 242 additions and 60 deletions.
16 changes: 10 additions & 6 deletions src/endpoints/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/routing/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/*")
Expand Down
30 changes: 16 additions & 14 deletions static-site/pages/groups/[...groupName].jsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,36 @@
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 -> <GroupSettingsPage>
* groups/:groupName/settings/members -> <GroupMembersPage>
* groups/:groupName[/*] -> <IndividualGroupPage>
* so we use some JS routing here to serve the right component
*/
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 <GroupSettingsPage groupName={query.groupName[0]}/>
const groupName = query.groupName[0]
const resourcePath = query.groupName.slice(1).join("/")

switch(resourcePath) {
case 'settings':
return <GroupSettingsPage groupName={groupName}/>
case 'settings/members':
return <GroupMembersPage groupName={groupName}/>
default:
return <IndividualGroupPage
groupName={groupName}
resourcePath={resourcePath.length ? resourcePath : undefined}
/>
}

const resourcePath = query.groupName.slice(1);

return (
<IndividualGroupPage groupName={query.groupName[0]} resourcePath={resourcePath.length ? resourcePath : undefined}/>
);

}

export default Index;
export default Index;
4 changes: 2 additions & 2 deletions static-site/src/layouts/generic-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => (
<MainLayout>
<div className="index-container">
<Helmet title={siteTitle} />
<main>
<UserDataWrapper>
<NavBar location={location} />
<NavBar/>
{banner}
<splashStyles.Container>
<HugeSpacer /><HugeSpacer />
Expand Down
4 changes: 2 additions & 2 deletions static-site/src/pages/contact.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { BigSpacer, FlexCenter} from "../layouts/generalComponents";
import { H1, NarrowFocusParagraph } from "../components/splash/styles";


const Contact = props => (
<GenericPage location={props.location}>
const Contact = () => (
<GenericPage>
<H1>Contact Us</H1>

<FlexCenter>
Expand Down
4 changes: 2 additions & 2 deletions static-site/src/pages/groups.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => (
<GenericPage location={props.location}>
const Index = () => (
<GenericPage>
<GroupsPage/>
</GenericPage>
);
Expand Down
2 changes: 1 addition & 1 deletion static-site/src/pages/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class Index extends React.Component {
<SEO/>
<main>
<UserDataWrapper>
<NavBar location={this.props.location} />
<NavBar/>
<Splash/>
</UserDataWrapper>
</main>
Expand Down
5 changes: 2 additions & 3 deletions static-site/src/pages/team.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,10 @@ const TeamPage = () => {
};


const Team = props => (
<GenericPage location={props.location}>
const Team = () => (
<GenericPage>
<TeamPage/>
</GenericPage>
);

export default Team;

2 changes: 1 addition & 1 deletion static-site/src/sections/community-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class Index extends React.Component {
render() {
const banner = this.banner();
return (
<GenericPage location={this.props.location} banner={banner}>
<GenericPage banner={banner}>
<splashStyles.H1>{title}</splashStyles.H1>
<SmallSpacer />

Expand Down
7 changes: 3 additions & 4 deletions static-site/src/sections/community-repo-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,21 @@ class Index extends React.Component {
}

render() {
const location = this.props.location;
const banner = this.banner();
if (this.state.repoNotFound) {
return (
<GenericPage location={location} banner={banner} />
<GenericPage banner={banner} />
);
}
if (!this.state.sourceInfo) {
return (
<GenericPage location={location} banner={banner}>
<GenericPage banner={banner}>
<splashStyles.H2>Data loading...</splashStyles.H2>
</GenericPage>
);
}
return (
<GenericPage location={location} banner={banner}>
<GenericPage banner={banner}>
<SourceInfoHeading sourceInfo={this.state.sourceInfo}/>
<HugeSpacer />
{this.state.sourceInfo.showDatasets && (
Expand Down
159 changes: 159 additions & 0 deletions static-site/src/sections/group-members-page.tsx
Original file line number Diff line number Diff line change
@@ -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<ErrorMessage>({title: "", contents: ""});
const [ roles, setRoles ] = useState<string[]>([]);
const [ members, setMembers ] = useState<GroupMember[]>([]);

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 (
<GenericPage banner={errorMessage.title ? <ErrorBanner {...errorMessage} /> : undefined}>
<FlexGridRight>
<splashStyles.Button to={uri`/groups/${groupName}`}>
Return to {`"${groupName}"`} Page
</splashStyles.Button>
</FlexGridRight>
<MediumSpacer/>

<splashStyles.H2>
{`"${groupName}"`} Group Members
</splashStyles.H2>
<BigSpacer/>

{roles && members
? <MembersTable members={members as GroupMember[]} />
: <splashStyles.H4>Fetching group members...</splashStyles.H4>}
</GenericPage>
)
};

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 (
<CenteredContainer>
<MembersTableContainer>
<div className="row no-gutters">
<div className="col">
<splashStyles.CenteredFocusParagraph>
<strong>Username</strong>
</splashStyles.CenteredFocusParagraph>
</div>
<div className="col">
<splashStyles.CenteredFocusParagraph>
<strong>Roles</strong>
</splashStyles.CenteredFocusParagraph>
</div>
</div>

{sortedMembers.map((member) =>
<div className="row no-gutters" key={member.username}>
<div className="col">
<splashStyles.CenteredFocusParagraph>
{member.username}
</splashStyles.CenteredFocusParagraph>
</div>
<div className="col">
<splashStyles.CenteredFocusParagraph>
{prettifyRoles(member.roles)}
</splashStyles.CenteredFocusParagraph>
</div>
</div>
)}
</MembersTableContainer>
</CenteredContainer>
)
};

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;
4 changes: 2 additions & 2 deletions static-site/src/sections/group-settings-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const UNAUTHORIZED_MESSAGE = <>
If your permissions have changed recently, try <a href="/logout">logging out</a> and logging back in.<br/>
</>;

const EditGroupSettingsPage = ({ location, groupName }) => {
const EditGroupSettingsPage = ({ groupName }) => {
const [ userAuthorized, setUserAuthorized ] = useState(null);
const [ errorMessage, setErrorMessage ] = useState();

Expand All @@ -39,7 +39,7 @@ const EditGroupSettingsPage = ({ location, groupName }) => {
};

return (
<GenericPage location={location}>
<GenericPage>
<FlexGridRight>
<splashStyles.Button to={uri`/groups/${groupName}`}>
Return to {`"${groupName}"`} Page
Expand Down
Loading

0 comments on commit abbe91c

Please sign in to comment.