From cbbcca78498823cf628acdc8b8a8dde14a2bb2ab Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 8 Aug 2023 16:36:09 +0400 Subject: [PATCH 1/2] Integrate members API Signed-off-by: Dmitriy Lazarev --- .../parodos/src/api/fetchAccessRequests.ts | 21 ++ .../parodos/src/api/fetchProjectMembers.ts | 20 ++ .../parodos/src/api/removeUserFromProject.ts | 22 ++ .../src/api/responseOnAccessRequest.ts | 23 ++ plugins/parodos/src/api/updateUserRole.ts | 23 ++ .../projects/ProjectAccessTable.tsx | 196 +++++++++--------- plugins/parodos/src/models/project.ts | 10 + 7 files changed, 215 insertions(+), 100 deletions(-) create mode 100644 plugins/parodos/src/api/fetchAccessRequests.ts create mode 100644 plugins/parodos/src/api/fetchProjectMembers.ts create mode 100644 plugins/parodos/src/api/removeUserFromProject.ts create mode 100644 plugins/parodos/src/api/responseOnAccessRequest.ts create mode 100644 plugins/parodos/src/api/updateUserRole.ts diff --git a/plugins/parodos/src/api/fetchAccessRequests.ts b/plugins/parodos/src/api/fetchAccessRequests.ts new file mode 100644 index 0000000..907ad76 --- /dev/null +++ b/plugins/parodos/src/api/fetchAccessRequests.ts @@ -0,0 +1,21 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { AccessRole } from '../models/project'; +import * as urls from '../urls'; + +// TODO There has been no API for this yet +export async function fetchAccessRequests( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, +): Promise<{ username: string; requestId: string; role: AccessRole }[]> { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/access`, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } + + return data; +} diff --git a/plugins/parodos/src/api/fetchProjectMembers.ts b/plugins/parodos/src/api/fetchProjectMembers.ts new file mode 100644 index 0000000..2df14e3 --- /dev/null +++ b/plugins/parodos/src/api/fetchProjectMembers.ts @@ -0,0 +1,20 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { projectMembers } from '../models/project'; +import * as urls from '../urls'; + +export async function fetchProjectMembers( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/members`, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } + + return projectMembers.parse(data); +} diff --git a/plugins/parodos/src/api/removeUserFromProject.ts b/plugins/parodos/src/api/removeUserFromProject.ts new file mode 100644 index 0000000..d12a2ab --- /dev/null +++ b/plugins/parodos/src/api/removeUserFromProject.ts @@ -0,0 +1,22 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import * as urls from '../urls'; + +export async function removeUserFromProject( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, + users: string[], +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/users`, + { + method: 'POST', + body: JSON.stringify(users), + }, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } +} diff --git a/plugins/parodos/src/api/responseOnAccessRequest.ts b/plugins/parodos/src/api/responseOnAccessRequest.ts new file mode 100644 index 0000000..bf77202 --- /dev/null +++ b/plugins/parodos/src/api/responseOnAccessRequest.ts @@ -0,0 +1,23 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { AccessRole } from '../models/project'; +import * as urls from '../urls'; + +export async function responseOnAccessRequest( + fetch: FetchApi['fetch'], + baseUrl: string, + requestId: string, + payload: { comment?: string; status: AccessRole }, +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/access/${requestId}/status`, + { + method: 'POST', + body: JSON.stringify({ ...payload, comment: payload.comment || '' }), + }, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } +} diff --git a/plugins/parodos/src/api/updateUserRole.ts b/plugins/parodos/src/api/updateUserRole.ts new file mode 100644 index 0000000..c9341df --- /dev/null +++ b/plugins/parodos/src/api/updateUserRole.ts @@ -0,0 +1,23 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { AccessRole } from '../models/project'; +import * as urls from '../urls'; + +export async function updateUserRole( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, + payload: { username: string; role: AccessRole }[], +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/users`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } +} diff --git a/plugins/parodos/src/components/projects/ProjectAccessTable.tsx b/plugins/parodos/src/components/projects/ProjectAccessTable.tsx index b3ddfc2..1680817 100644 --- a/plugins/parodos/src/components/projects/ProjectAccessTable.tsx +++ b/plugins/parodos/src/components/projects/ProjectAccessTable.tsx @@ -1,4 +1,5 @@ -import { Table, TableColumn } from '@backstage/core-components'; +import { Progress, Table, TableColumn } from '@backstage/core-components'; +import { errorApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -13,8 +14,14 @@ import { } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import classNames from 'classnames'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; import { AccessRole, Project } from '../../models/project'; +import { useStore } from '../../stores/workflowStore/workflowStore'; +import useAsync from 'react-use/lib/useAsync'; +import { fetchProjectMembers } from '../../api/fetchProjectMembers'; +import { updateUserRole } from '../../api/updateUserRole'; +import { removeUserFromProject } from '../../api/removeUserFromProject'; export interface ProjectAccessTableProps { project: Project; @@ -27,54 +34,6 @@ interface AccessTableData { const roles: AccessRole[] = ['Owner', 'Admin', 'Developer']; -const mockMembers: { name: string; role: AccessRole }[] = [ - { name: 'Luke Shannon', role: 'Owner' }, - { name: 'Piotr Kliczewski', role: 'Admin' }, - { name: 'Richard Wang', role: 'Developer' }, - { name: 'Moti Asayag', role: 'Developer' }, - { name: 'Paul Cowan', role: 'Developer' }, - { name: 'Dmitriy Lazarev', role: 'Developer' }, -]; - -function useMockMembers() { - const [members, setMembers] = useState(mockMembers); - - const addMember = useCallback( - (name: string, role: AccessRole) => - setMembers(prevMembers => [...prevMembers, { name, role }]), - [], - ); - const removeMember = useCallback( - (name: string) => - setMembers(prevMembers => - prevMembers.filter(member => member.name !== name), - ), - [], - ); - const transferOwnership = useCallback( - (name: string) => - setMembers(prevMembers => - prevMembers.map(member => { - if (member.name === name) return { ...member, role: 'Owner' }; - if (member.role === 'Owner') return { ...member, role: 'Developer' }; - return member; - }), - ), - [], - ); - const changeRole = useCallback( - (name: string, role: Exclude) => - setMembers(prevMembers => - prevMembers.map(member => - member.name === name ? { ...member, role } : member, - ), - ), - [], - ); - - return { members, addMember, removeMember, transferOwnership, changeRole }; -} - const useStyles = makeStyles(theme => ({ root: { padding: theme.spacing(0), @@ -110,10 +69,9 @@ const useStyles = makeStyles(theme => ({ export function ProjectAccessTable({ project, }: ProjectAccessTableProps): JSX.Element { - // TODO Use real data when it will be available - const { members, addMember, removeMember, transferOwnership, changeRole } = - useMockMembers(); - + const { fetch } = useApi(fetchApiRef); + const errorApi = useApi(errorApiRef); + const baseUrl = useStore(state => state.baseUrl); const classes = useStyles(); const [snackbarMessage, setSnackbarMessage] = useState(); const [undoRemove, setUndoRemove] = useState<() => void>(); @@ -121,6 +79,15 @@ export function ProjectAccessTable({ { name: string; role: AccessRole }[] >([]); + const { + error: getMembersError, + loading, + value: members = [], + } = useAsync( + async () => fetchProjectMembers(fetch, baseUrl, project.id), + [fetch, baseUrl, project.id], + ); + const columns: TableColumn[] = [ { title: `PROJECT MEMBERS (${members.length})`, @@ -131,10 +98,58 @@ export function ProjectAccessTable({ ]; const tableData: AccessTableData[] = members.map(member => ({ - member: member.name, - roles: roles.map(role => [role, role === member.role]), + member: `${member.firstName} ${member.lastName}`, + roles: roles.map(role => [role, member.roles.includes(role)]), })); + const [{ error: changeRoleError }, changeRole] = useAsyncFn( + async (username: string, role: Exclude) => { + await updateUserRole(fetch, baseUrl, project.id, [{ username, role }]); + setSnackbarMessage( + `User role for ${username} has been successfully changed to ${role}.`, + ); + }, + [fetch, baseUrl, project.id], + ); + + const [{ error: transferOwnershipError }, transferOwnership] = useAsyncFn( + async (username: string) => { + const { username: ownerUsername } = + members.find(member => member.roles.includes('Owner')) ?? {}; + await updateUserRole(fetch, baseUrl, project.id, [ + { username, role: 'Owner' }, + ...(ownerUsername + ? [{ username: ownerUsername, role: 'Admin' as AccessRole }] + : []), + ]); + setSnackbarMessage( + `User ownership has been successfully transferred to ${username}.`, + ); + }, + [fetch, baseUrl, project.id, members], + ); + + const [{ error: removeMembersError }, removeMembers] = + useAsyncFn(async () => { + const users = selectedMembers.map(({ name }) => name); + await removeUserFromProject(fetch, baseUrl, project.id, users); + setSelectedMembers([]); + setSnackbarMessage( + `You have successfully removed ${selectedMembers.length} contributor${ + selectedMembers.length > 1 ? 's' : '' + } from this project.`, + ); + setUndoRemove( + () => () => + updateUserRole( + fetch, + baseUrl, + project.id, + selectedMembers.map(({ name, role }) => ({ username: name, role })), + ), + ); + }, [fetch, baseUrl, project.id, selectedMembers]); + const handleSelectMember = useCallback( (event: React.ChangeEvent<{}>, selected: boolean) => { const name = @@ -142,7 +157,7 @@ export function ProjectAccessTable({ if (selected) { setSelectedMembers(prevSelected => [ ...prevSelected, - members.find(member => member.name === name)!, + members.find(member => member.username === name)!, ]); } else { setSelectedMembers(prevSelected => @@ -153,25 +168,25 @@ export function ProjectAccessTable({ [members], ); - const handleTransferOwnership = useCallback( - (name: string) => { - transferOwnership(name); - setSnackbarMessage( - `User ownership has been successfully transferred to ${name}.`, - ); - }, - [transferOwnership], - ); + useEffect(() => { + const error = + getMembersError || + changeRoleError || + transferOwnershipError || + removeMembersError; + if (error) { + // eslint-disable-next-line no-console + console.error(error); - const handleChangeRole = useCallback( - (name: string, role: Exclude) => { - changeRole(name, role); - setSnackbarMessage( - `User role for ${name} has been successfully changed to ${role}.`, - ); - }, - [changeRole], - ); + errorApi.post(new Error('Failed to update project access')); + } + }, [ + errorApi, + getMembersError, + changeRoleError, + transferOwnershipError, + removeMembersError, + ]); const Cell = useCallback( ({ columnDef, rowData }) => { @@ -209,8 +224,8 @@ export function ProjectAccessTable({ onChange={() => { if (selected) return; if (roleName === 'Owner') - handleTransferOwnership(rowData.member); - else handleChangeRole(rowData.member, roleName); + transferOwnership(rowData.member); + else changeRole(rowData.member, roleName); }} /> @@ -222,30 +237,9 @@ export function ProjectAccessTable({ } return {rowData[columnDef.field]}; }, - [ - project.accessRole, - handleSelectMember, - handleTransferOwnership, - handleChangeRole, - ], + [project.accessRole, handleSelectMember, transferOwnership, changeRole], ); - const handleRemoveSelected = () => { - selectedMembers.forEach(member => removeMember(member.name)); - setSelectedMembers([]); - setSnackbarMessage( - `You have successfully removed ${selectedMembers.length} contributor${ - selectedMembers.length > 1 ? 's' : '' - } from this project.`, - ); - setUndoRemove( - () => () => - [...selectedMembers] - .reverse() - .forEach(member => addMember(member.name, member.role)), - ); - }; - return ( <> (), }} /> + {loading && } diff --git a/plugins/parodos/src/models/project.ts b/plugins/parodos/src/models/project.ts index 7da4f79..e1fb33f 100644 --- a/plugins/parodos/src/models/project.ts +++ b/plugins/parodos/src/models/project.ts @@ -18,6 +18,16 @@ export type AccessRole = z.infer; export type ProjectStatus = z.infer; +export const projectMember = z.object({ + firstName: z.string(), + lastName: z.string(), + roles: z.array(accessRole), +}); + +export const projectMembers = z.array(projectMember); + +export type ProjectMember = z.infer; + export const projectSchema = z.object({ id: z.string(), name: z.string(), From 3f7c32d8be2e368130fa4d4bbd43458fb9aa565e Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 15 Aug 2023 15:44:19 +0400 Subject: [PATCH 2/2] Improve access roles management Signed-off-by: Dmitriy Lazarev --- .../parodos/src/api/fetchAccessRequests.ts | 21 +- .../parodos/src/api/removeUserFromProject.ts | 2 +- .../src/api/responseOnAccessRequest.ts | 13 +- plugins/parodos/src/api/updateUserRole.ts | 2 +- .../projects/ProjectAccessTable.tsx | 385 ++++++++++++++---- .../src/components/projects/ProjectsTable.tsx | 38 +- plugins/parodos/src/models/project.ts | 32 +- .../src/stores/slices/projectsSlice.ts | 3 +- plugins/parodos/src/stores/types.ts | 2 +- 9 files changed, 376 insertions(+), 122 deletions(-) diff --git a/plugins/parodos/src/api/fetchAccessRequests.ts b/plugins/parodos/src/api/fetchAccessRequests.ts index 907ad76..fdc2f03 100644 --- a/plugins/parodos/src/api/fetchAccessRequests.ts +++ b/plugins/parodos/src/api/fetchAccessRequests.ts @@ -1,21 +1,26 @@ import { FetchApi } from '@backstage/core-plugin-api'; -import { AccessRole } from '../models/project'; +import { AccessRequest, accessRequests } from '../models/project'; import * as urls from '../urls'; -// TODO There has been no API for this yet export async function fetchAccessRequests( fetch: FetchApi['fetch'], baseUrl: string, - projectId: string, -): Promise<{ username: string; requestId: string; role: AccessRole }[]> { - const response = await fetch( - `${baseUrl}${urls.Projects}/${projectId}/access`, - ); + filter?: { projectId?: string; username?: string }, +): Promise { + const response = await fetch(`${baseUrl}${urls.Projects}/access/pending`); const data = await response.json(); if ('error' in data) { throw new Error(`${data.error}: ${data.path}`); } - return data; + const requests = accessRequests.parse(data); + + return filter + ? requests.filter( + request => + (filter.projectId ? request.projectId === filter.projectId : true) && + (filter.username ? request.username === filter.username : true), + ) + : requests; } diff --git a/plugins/parodos/src/api/removeUserFromProject.ts b/plugins/parodos/src/api/removeUserFromProject.ts index d12a2ab..5e1c0c7 100644 --- a/plugins/parodos/src/api/removeUserFromProject.ts +++ b/plugins/parodos/src/api/removeUserFromProject.ts @@ -10,7 +10,7 @@ export async function removeUserFromProject( const response = await fetch( `${baseUrl}${urls.Projects}/${projectId}/users`, { - method: 'POST', + method: 'DELETE', body: JSON.stringify(users), }, ); diff --git a/plugins/parodos/src/api/responseOnAccessRequest.ts b/plugins/parodos/src/api/responseOnAccessRequest.ts index bf77202..1a2a47a 100644 --- a/plugins/parodos/src/api/responseOnAccessRequest.ts +++ b/plugins/parodos/src/api/responseOnAccessRequest.ts @@ -1,12 +1,12 @@ import { FetchApi } from '@backstage/core-plugin-api'; -import { AccessRole } from '../models/project'; +import { AccessStatus } from '../models/project'; import * as urls from '../urls'; export async function responseOnAccessRequest( fetch: FetchApi['fetch'], baseUrl: string, requestId: string, - payload: { comment?: string; status: AccessRole }, + payload: { comment?: string; status: AccessStatus }, ) { const response = await fetch( `${baseUrl}${urls.Projects}/access/${requestId}/status`, @@ -15,9 +15,12 @@ export async function responseOnAccessRequest( body: JSON.stringify({ ...payload, comment: payload.comment || '' }), }, ); - const data = await response.json(); + if (response.status !== 204) { + if (response.status === 400) { + const data = await response.json(); + throw new Error(`${response.status}: ${data.message}`); + } - if ('error' in data) { - throw new Error(`${data.error}: ${data.path}`); + throw new Error(`${response.status}`); } } diff --git a/plugins/parodos/src/api/updateUserRole.ts b/plugins/parodos/src/api/updateUserRole.ts index c9341df..387c1f9 100644 --- a/plugins/parodos/src/api/updateUserRole.ts +++ b/plugins/parodos/src/api/updateUserRole.ts @@ -6,7 +6,7 @@ export async function updateUserRole( fetch: FetchApi['fetch'], baseUrl: string, projectId: string, - payload: { username: string; role: AccessRole }[], + payload: { username: string; roles: AccessRole[] }[], ) { const response = await fetch( `${baseUrl}${urls.Projects}/${projectId}/users`, diff --git a/plugins/parodos/src/components/projects/ProjectAccessTable.tsx b/plugins/parodos/src/components/projects/ProjectAccessTable.tsx index 1680817..2fbf8bd 100644 --- a/plugins/parodos/src/components/projects/ProjectAccessTable.tsx +++ b/plugins/parodos/src/components/projects/ProjectAccessTable.tsx @@ -1,5 +1,10 @@ import { Progress, Table, TableColumn } from '@backstage/core-components'; -import { errorApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; +import { + errorApiRef, + fetchApiRef, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -11,28 +16,53 @@ import { Snackbar, SnackbarContent, TableCell, + TextField, + Typography, } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import classNames from 'classnames'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; -import { AccessRole, Project } from '../../models/project'; +import { + AccessRequest, + AccessRole, + AccessStatus, + Project, + ProjectMember, +} from '../../models/project'; import { useStore } from '../../stores/workflowStore/workflowStore'; -import useAsync from 'react-use/lib/useAsync'; import { fetchProjectMembers } from '../../api/fetchProjectMembers'; import { updateUserRole } from '../../api/updateUserRole'; import { removeUserFromProject } from '../../api/removeUserFromProject'; +import { fetchAccessRequests } from '../../api/fetchAccessRequests'; +import { responseOnAccessRequest } from '../../api/responseOnAccessRequest'; +import { useNavigate } from 'react-router-dom'; +import { pluginRoutePrefix } from '../ParodosPage/navigationMap'; export interface ProjectAccessTableProps { project: Project; } interface AccessTableData { + requestId?: string; + username: string; member: string; - roles: [AccessRole, boolean][]; + roles: { name: string; disabled: boolean; selected: boolean }[]; + disabled: boolean; + selected: boolean; } -const roles: AccessRole[] = ['Owner', 'Admin', 'Developer']; +const accessRoles: AccessRole[] = ['OWNER', 'ADMIN', 'DEVELOPER']; +const accessRoleMap: Record = { + OWNER: 'Owner', + ADMIN: 'Admin', + DEVELOPER: 'Developer', +} as const; +const accessRoleBackMap: Record = { + Owner: 'OWNER', + Admin: 'ADMIN', + Developer: 'DEVELOPER', +}; const useStyles = makeStyles(theme => ({ root: { @@ -64,6 +94,15 @@ const useStyles = makeStyles(theme => ({ success: { backgroundColor: theme.palette.status.ok, }, + roleRequestMessage: { + display: 'flex', + }, + commentTextfield: { + width: '100%', + }, + responseButtons: { + flexShrink: 0, + }, })); export function ProjectAccessTable({ @@ -71,20 +110,31 @@ export function ProjectAccessTable({ }: ProjectAccessTableProps): JSX.Element { const { fetch } = useApi(fetchApiRef); const errorApi = useApi(errorApiRef); + const identityApi = useApi(identityApiRef); + const navigate = useNavigate(); const baseUrl = useStore(state => state.baseUrl); + const fetchProjects = useStore(state => state.fetchProjects); const classes = useStyles(); const [snackbarMessage, setSnackbarMessage] = useState(); const [undoRemove, setUndoRemove] = useState<() => void>(); const [selectedMembers, setSelectedMembers] = useState< - { name: string; role: AccessRole }[] + { username: string; roles: AccessRole[] }[] >([]); + const [members, setMembers] = useState([]); + const [requests, setRequests] = useState([]); + const [currentUser, setCurrentUser] = useState(); + + const [{ error: getMembersError, loading: loadingMembers }, getMembers] = + useAsyncFn( + () => fetchProjectMembers(fetch, baseUrl, project.id), + [fetch, baseUrl, project.id], + ); - const { - error: getMembersError, - loading, - value: members = [], - } = useAsync( - async () => fetchProjectMembers(fetch, baseUrl, project.id), + const [ + { error: getAccessRequestsError, loading: loadingAccessRequests }, + getRequests, + ] = useAsyncFn( + () => fetchAccessRequests(fetch, baseUrl, { projectId: project.id }), [fetch, baseUrl, project.id], ); @@ -94,61 +144,144 @@ export function ProjectAccessTable({ field: 'member', width: '15%', }, - { title: 'ROLE', field: 'roles' }, + { title: 'ROLES', field: 'roles' }, ]; - const tableData: AccessTableData[] = members.map(member => ({ - member: `${member.firstName} ${member.lastName}`, - roles: roles.map(role => [role, member.roles.includes(role)]), - })); + const currentMember = members.find( + ({ username }) => username === currentUser, + ); + const owners = members.filter(({ roles }) => roles.includes('OWNER')); + const selectedOwners = selectedMembers.filter(({ roles }) => + roles.includes('OWNER'), + ); + const isOwner = currentMember?.roles.includes('OWNER') ?? false; + const isAdmin = currentMember?.roles.includes('ADMIN') ?? false; - const [{ error: changeRoleError }, changeRole] = useAsyncFn( - async (username: string, role: Exclude) => { - await updateUserRole(fetch, baseUrl, project.id, [{ username, role }]); - setSnackbarMessage( - `User role for ${username} has been successfully changed to ${role}.`, + const tableData: AccessTableData[] = [...requests, ...members].map(member => { + const selected = selectedMembers.some( + ({ username }) => username === member.username, + ); + const roles = 'role' in member ? [member.role] : member.roles; + return { + requestId: + 'accessRequestId' in member ? member.accessRequestId : undefined, + username: member.username, + member: + 'accessRequestId' in member + ? `${member.firstname} ${member.lastname}` + : `${member.firstName} ${member.lastName}`, + roles: accessRoles.map(role => { + const selectedRole = roles.includes(role); + return { + name: accessRoleMap[role], + selected: selectedRole, + disabled: + (!isOwner && !isAdmin) || + (selectedRole && roles.length <= 1) || + (!isOwner && role === 'OWNER') || + (selectedRole && role === 'OWNER' && owners.length <= 1), + }; + }), + selected, + disabled: + !isOwner || + (!selected && + owners.length - selectedOwners.length <= 1 && + roles.includes('OWNER')), + }; + }); + + useEffect(() => { + if (owners.length === selectedOwners.length) { + setSelectedMembers(prevSelected => + prevSelected.filter(({ roles }) => !roles.includes('OWNER')), ); - }, - [fetch, baseUrl, project.id], - ); + } + }, [owners, selectedOwners]); - const [{ error: transferOwnershipError }, transferOwnership] = useAsyncFn( - async (username: string) => { - const { username: ownerUsername } = - members.find(member => member.roles.includes('Owner')) ?? {}; - await updateUserRole(fetch, baseUrl, project.id, [ - { username, role: 'Owner' }, - ...(ownerUsername - ? [{ username: ownerUsername, role: 'Admin' as AccessRole }] - : []), - ]); + const [{ error: changeRoleError }, changeRole] = useAsyncFn( + async (username: string, role: AccessRole) => { + const roles = members.find(member => member.username === username)!.roles; + if (roles.includes(role)) { + roles.splice(roles.indexOf(role), 1); + } else { + roles.push(role as AccessRole); + } + await updateUserRole(fetch, baseUrl, project.id, [{ username, roles }]); + setMembers(await getMembers()); setSnackbarMessage( - `User ownership has been successfully transferred to ${username}.`, + `User role${ + roles.length > 1 ? 's' : '' + } for ${username} has been successfully changed to ${roles.join( + ', ', + )}.`, ); }, - [fetch, baseUrl, project.id, members], + [fetch, baseUrl, project.id, members, getMembers], ); const [{ error: removeMembersError }, removeMembers] = useAsyncFn(async () => { - const users = selectedMembers.map(({ name }) => name); + const users = selectedMembers.map(({ username }) => username); await removeUserFromProject(fetch, baseUrl, project.id, users); + setMembers(await getMembers()); setSelectedMembers([]); setSnackbarMessage( `You have successfully removed ${selectedMembers.length} contributor${ selectedMembers.length > 1 ? 's' : '' } from this project.`, ); - setUndoRemove( - () => () => - updateUserRole( - fetch, - baseUrl, - project.id, - selectedMembers.map(({ name, role }) => ({ username: name, role })), - ), + if (selectedMembers.some(({ username }) => username === currentUser)) { + fetchProjects(fetch, true); + navigate(`${pluginRoutePrefix}/projects`); + } else { + setUndoRemove(() => async () => { + await updateUserRole(fetch, baseUrl, project.id, selectedMembers); + setMembers(await getMembers()); + }); + } + }, [ + selectedMembers, + fetch, + baseUrl, + project.id, + getMembers, + currentUser, + fetchProjects, + navigate, + ]); + + const [{ error: responseRequestError }, responseRequest] = useAsyncFn( + async (requestId: string, status: AccessStatus, comment?: string) => { + await responseOnAccessRequest(fetch, baseUrl, requestId, { + comment, + status, + }); + setMembers(await getMembers()); + setRequests(await getRequests()); + setSnackbarMessage( + `You have successfully ${ + status === 'APPROVED' ? 'approved' : 'rejected' + } the request.`, + ); + }, + [fetch, baseUrl, getMembers, getRequests], + ); + + const handleRequestAccess = useCallback( + (e: SyntheticEvent) => { + e.preventDefault(); + const { submitter } = e.nativeEvent; + const { requestId, comment, reject } = e.currentTarget; + + responseRequest( + requestId.value, + reject === submitter ? 'REJECTED' : 'APPROVED', + comment.value, ); - }, [fetch, baseUrl, project.id, selectedMembers]); + }, + [responseRequest], + ); const handleSelectMember = useCallback( (event: React.ChangeEvent<{}>, selected: boolean) => { @@ -161,7 +294,7 @@ export function ProjectAccessTable({ ]); } else { setSelectedMembers(prevSelected => - prevSelected.filter(member => member.name !== name), + prevSelected.filter(member => member.username !== name), ); } }, @@ -171,38 +304,125 @@ export function ProjectAccessTable({ useEffect(() => { const error = getMembersError || + getAccessRequestsError || changeRoleError || - transferOwnershipError || - removeMembersError; + removeMembersError || + responseRequestError; if (error) { // eslint-disable-next-line no-console console.error(error); - errorApi.post(new Error('Failed to update project access')); + if (changeRoleError || removeMembersError || responseRequestError) { + errorApi.post(new Error('Failed to update project access')); + } + if (getMembersError || getAccessRequestsError) { + errorApi.post(new Error('Failed to get project members')); + } } }, [ errorApi, getMembersError, + getAccessRequestsError, changeRoleError, - transferOwnershipError, removeMembersError, + responseRequestError, ]); + useEffect(() => { + getMembers().then(setMembers); + getRequests().then(setRequests); + identityApi + .getProfileInfo() + .then(({ displayName }) => setCurrentUser(displayName)); + }, [getMembers, getRequests, identityApi]); + const Cell = useCallback( ({ columnDef, rowData }) => { - const isOwner = rowData.roles.some( - ([roleName, selected]: [AccessRole, boolean]) => - roleName === 'Owner' && selected, - ); + if (rowData.requestId) { + const { name: roleName } = rowData.roles.find( + ({ + selected, + }: { + name: string; + disabled: boolean; + selected: boolean; + }) => selected, + )!; + if (columnDef.field === 'member') { + return ( + + {rowData.member} + + ); + } else if (columnDef.field === 'roles') { + return ( + +
+ + + + {`Requested access to the project with "${roleName}" role.`} + + + + + + + + + + + + + + + +
+
+ ); + } + } + if (columnDef.field === 'member') { return ( } label={rowData.member} - name={rowData.member} - disabled={isOwner || project.accessRole !== 'Owner'} + name={rowData.username} + disabled={rowData.disabled} onChange={handleSelectMember} + checked={rowData.selected} /> ); @@ -211,25 +431,29 @@ export function ProjectAccessTable({ {rowData.roles.map( - ([roleName, selected]: [AccessRole, boolean]) => ( - - } - label={roleName} - checked={selected} - disabled={ - isOwner || - (project.accessRole !== 'Owner' && roleName === 'Owner') - } - onChange={() => { - if (selected) return; - if (roleName === 'Owner') - transferOwnership(rowData.member); - else changeRole(rowData.member, roleName); - }} - /> - - ), + ({ + name, + selected, + disabled, + }: { + name: string; + disabled: boolean; + selected: boolean; + }) => { + return ( + + } + label={name} + checked={selected} + disabled={disabled} + onChange={() => + changeRole(rowData.username, accessRoleBackMap[name]) + } + /> + + ); + }, )} @@ -237,7 +461,7 @@ export function ProjectAccessTable({ } return {rowData[columnDef.field]}; }, - [project.accessRole, handleSelectMember, transferOwnership, changeRole], + [handleRequestAccess, classes, handleSelectMember, changeRole], ); return ( @@ -293,10 +517,9 @@ export function ProjectAccessTable({ data={tableData} components={{ Cell, - // Row: ({ rowData, ...props }) => (), }} /> - {loading && } + {(loadingMembers || loadingAccessRequests) && }