Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add page for listing Group members #973

Merged
merged 9 commits into from
Aug 15, 2024
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