From 3d81c1a3a4527d249491d1cf5e4bb9da170b6d9f Mon Sep 17 00:00:00 2001 From: Tobias Kampmann Date: Fri, 22 Dec 2023 10:26:55 +0100 Subject: [PATCH] feat(website): create groupManagementApi and add custom hooks and use on GroupPage and Manager, adapt e2e * adapt endpoint to be more restful --- .../backend/controller/ExceptionHandler.kt | 13 +- .../controller/GroupManagementController.kt | 17 +- .../GroupManagementDatabaseService.kt | 6 +- .../GroupManagementControllerClient.kt | 9 +- .../GroupManagementControllerTest.kt | 4 +- website/src/components/ConfirmationDialog.tsx | 10 +- website/src/components/DataUploadForm.tsx | 68 +++++++- website/src/components/ErrorFeedback.tsx | 4 +- .../Submission/SubmissionForm.spec.tsx | 29 ++- website/src/components/User/GroupManager.tsx | 120 +++++++------ website/src/components/User/GroupPage.tsx | 104 ++++++----- website/src/components/User/UserPage.astro | 18 +- website/src/env.d.ts | 1 + website/src/hooks/useGroupOperations.ts | 165 ++++++++++++++++++ website/src/middleware/authMiddleware.ts | 5 +- .../[organism]/group/[groupName]/index.astro | 19 ++ .../src/pages/[organism]/group/index.astro | 13 -- website/src/services/backendApi.ts | 62 +------ website/src/services/commonApiTypes.ts | 25 +++ website/src/services/groupManagementApi.ts | 71 ++++++++ website/src/services/groupManagementClient.ts | 19 ++ website/src/services/serviceHooks.ts | 5 + website/src/services/zodiosWrapperClient.ts | 6 +- website/src/types/backend.ts | 14 ++ website/src/utils/stringifyMaybeAxiosError.ts | 13 +- website/tests/e2e.fixture.ts | 2 + website/tests/playwrightSetup.ts | 27 +-- website/tests/util/backendCalls.ts | 27 +-- website/vitest.setup.ts | 10 ++ 29 files changed, 640 insertions(+), 246 deletions(-) create mode 100644 website/src/hooks/useGroupOperations.ts create mode 100644 website/src/pages/[organism]/group/[groupName]/index.astro delete mode 100644 website/src/pages/[organism]/group/index.astro create mode 100644 website/src/services/commonApiTypes.ts create mode 100644 website/src/services/groupManagementApi.ts create mode 100644 website/src/services/groupManagementClient.ts diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/ExceptionHandler.kt b/backend/src/main/kotlin/org/loculus/backend/controller/ExceptionHandler.kt index f6c701e56..942e144ac 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/ExceptionHandler.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/ExceptionHandler.kt @@ -77,6 +77,17 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { ) } + @ExceptionHandler(ConflictException::class) + @ResponseStatus(HttpStatus.CONFLICT) + fun handleConflictException(e: Exception): ResponseEntity { + log.warn(e) { "Caught conflict exception: ${e.message}" } + + return responseEntity( + HttpStatus.CONFLICT, + e.message, + ) + } + @ExceptionHandler(NotFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) fun handleNotFoundException(e: NotFoundException): ResponseEntity { @@ -149,5 +160,5 @@ class UnprocessableEntityException(message: String) : RuntimeException(message) class NotFoundException(message: String) : RuntimeException(message) class ProcessingValidationException(message: String) : RuntimeException(message) class DuplicateKeyException(message: String) : RuntimeException(message) - +class ConflictException(message: String) : RuntimeException(message) class DummyUnauthorizedExceptionToMakeItAppearInSwaggerUi : RuntimeException() diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt index 334ccfcf7..ed55085fa 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController @@ -60,7 +61,7 @@ class GroupManagementController( @Operation(description = "Add user to a group.") @ResponseStatus(HttpStatus.NO_CONTENT) - @PostMapping("/groups/{groupName}/users", produces = [MediaType.APPLICATION_JSON_VALUE]) + @PutMapping("/groups/{groupName}/users/{usernameToAdd}", produces = [MediaType.APPLICATION_JSON_VALUE]) fun addUserToGroup( @UsernameFromJwt groupMember: String, @Parameter( @@ -68,12 +69,12 @@ class GroupManagementController( ) @PathVariable groupName: String, @Parameter( description = "The user name that should be added to the group.", - ) @RequestBody usernameToAdd: Username, - ) = groupManagementDatabaseService.addUserToGroup(groupMember, groupName, usernameToAdd.username) + ) @PathVariable usernameToAdd: String, + ) = groupManagementDatabaseService.addUserToGroup(groupMember, groupName, usernameToAdd) @Operation(description = "Remove user from a group.") @ResponseStatus(HttpStatus.NO_CONTENT) - @DeleteMapping("/groups/{groupName}/users", produces = [MediaType.APPLICATION_JSON_VALUE]) + @DeleteMapping("/groups/{groupName}/users/{usernameToRemove}", produces = [MediaType.APPLICATION_JSON_VALUE]) fun removeUserFromGroup( @UsernameFromJwt groupMember: String, @Parameter( @@ -81,14 +82,10 @@ class GroupManagementController( ) @PathVariable groupName: String, @Parameter( description = "The user name that should be removed from the group.", - ) @RequestBody usernameToRemove: Username, - ) = groupManagementDatabaseService.removeUserFromGroup(groupMember, groupName, usernameToRemove.username) + ) @PathVariable usernameToRemove: String, + ) = groupManagementDatabaseService.removeUserFromGroup(groupMember, groupName, usernameToRemove) data class GroupName( val groupName: String, ) - - data class Username( - val username: String, - ) } diff --git a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt index 5f47d563d..538599fcb 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt @@ -9,7 +9,7 @@ import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.selectAll import org.loculus.backend.api.Group import org.loculus.backend.api.GroupDetails -import org.loculus.backend.controller.BadRequestException +import org.loculus.backend.controller.ConflictException import org.loculus.backend.model.UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -36,7 +36,7 @@ class GroupManagementDatabaseService( } } catch (e: ExposedSQLException) { if (e.sqlState == UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE) { - throw BadRequestException( + throw ConflictException( "Group name already exists. Please choose a different name.", ) } @@ -65,7 +65,7 @@ class GroupManagementDatabaseService( } } catch (e: ExposedSQLException) { if (e.sqlState == UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE) { - throw BadRequestException( + throw ConflictException( "User $usernameToAdd is already member of the group $groupName.", ) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt index d81d8cc0a..934aace64 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt @@ -8,6 +8,7 @@ import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put class GroupManagementControllerClient(private val mockMvc: MockMvc) { fun createNewGroup(groupName: String = NEW_GROUP, jwt: String? = jwtForDefaultUser): ResultActions = @@ -34,9 +35,7 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc) { groupName: String = NEW_GROUP, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( - post("/groups/$groupName/users") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .content("""{"username":"$usernameToAdd"}""") + put("/groups/$groupName/users/$usernameToAdd") .withAuth(jwt), ) @@ -45,9 +44,7 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc) { groupName: String = NEW_GROUP, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( - delete("/groups/$groupName/users") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .content("""{"username":"$userToRemove"}""") + delete("/groups/$groupName/users/$userToRemove") .withAuth(jwt), ) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt index 7cae6efae..fa1cdba7e 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt @@ -56,7 +56,7 @@ class GroupManagementControllerTest( fun `GIVEN an existing group WHEN creating a group with same name THEN this is a bad request`() { client.createNewGroup().andExpect(status().isNoContent) - client.createNewGroup().andExpect(status().isBadRequest) + client.createNewGroup().andExpect(status().isConflict) } @Test @@ -140,7 +140,7 @@ class GroupManagementControllerTest( .andExpect(status().isNoContent) client.addUserToGroup(DEFAULT_USER_NAME) - .andExpect(status().isBadRequest) + .andExpect(status().isConflict) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect( jsonPath("\$.detail").value( diff --git a/website/src/components/ConfirmationDialog.tsx b/website/src/components/ConfirmationDialog.tsx index 2204505ea..145c9af88 100644 --- a/website/src/components/ConfirmationDialog.tsx +++ b/website/src/components/ConfirmationDialog.tsx @@ -14,14 +14,14 @@ export const ConfirmationDialog: FC = ({ dialogText, on

{dialogText}

-
+
- +
- +
diff --git a/website/src/components/DataUploadForm.tsx b/website/src/components/DataUploadForm.tsx index 1d778464e..abba52458 100644 --- a/website/src/components/DataUploadForm.tsx +++ b/website/src/components/DataUploadForm.tsx @@ -1,10 +1,11 @@ import { CircularProgress, TextField } from '@mui/material'; import { isErrorFromAlias } from '@zodios/core'; import type { AxiosError } from 'axios'; -import { type ChangeEvent, type FormEvent, useState } from 'react'; +import { type ChangeEvent, type FormEvent, useMemo, useState } from 'react'; import { withQueryProvider } from './common/withQueryProvider.tsx'; import { getClientLogger } from '../clientLogger.ts'; +import { useGroupManagementClient } from '../hooks/useGroupOperations.ts'; import { backendApi } from '../services/backendApi.ts'; import { backendClientHooks } from '../services/serviceHooks.ts'; import type { SubmissionIdMapping } from '../types/backend.ts'; @@ -36,7 +37,22 @@ const InnerDataUploadForm = ({ const [metadataFile, setMetadataFile] = useState(null); const [sequenceFile, setSequenceFile] = useState(null); + const { zodiosHooks } = useGroupManagementClient(clientConfig); + const groupsOfUser = zodiosHooks.useGetGroupsOfUser({ + headers: createAuthorizationHeader(accessToken), + }); + + if (groupsOfUser.error) { + onError(`Failed to query Groups: ${stringifyMaybeAxiosError(groupsOfUser.error)}`); + } + + const noGroup = useMemo( + () => groupsOfUser.data === undefined || groupsOfUser.data.length === 0, + [groupsOfUser.data], + ); + const { submit, revise, isLoading } = useSubmitFiles(accessToken, organism, clientConfig, onSuccess, onError); + const [selectedGroup, setSelectedGroup] = useState(undefined); const handleLoadExampleData = async () => { const { metadataFileContent, revisedMetadataFileContent, sequenceFileContent } = getExampleData(); @@ -53,15 +69,23 @@ const InnerDataUploadForm = ({ const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!metadataFile || !sequenceFile) { - onError('Please select both a metadata and sequences file'); + if (!metadataFile) { + onError('Please select metadata file'); + return; + } + if (!sequenceFile) { + onError('Please select a sequences file'); return; } switch (action) { case 'submit': - // TODO(672): Allow user to specify group name. For now, use default group name from tests. - submit({ metadataFile, sequenceFile, groupName: 'testGroup' }); + const groupName = selectedGroup ?? groupsOfUser.data?.[0].groupName; + if (groupName === undefined) { + onError('Please select a group'); + return; + } + submit({ metadataFile, sequenceFile, groupName }); break; case 'revise': revise({ metadataFile, sequenceFile }); @@ -71,6 +95,33 @@ const InnerDataUploadForm = ({ return (
+ {action === 'submit' && + (noGroup ? ( + groupsOfUser.isLoading ? ( +

Loading groups...

+ ) : ( +

No group found. Please join or create a group.

+ ) + ) : ( +
+ Submitting for: + +
+ ))} + +
-
diff --git a/website/src/components/ErrorFeedback.tsx b/website/src/components/ErrorFeedback.tsx index f039ad319..865927da9 100644 --- a/website/src/components/ErrorFeedback.tsx +++ b/website/src/components/ErrorFeedback.tsx @@ -4,8 +4,9 @@ import { type FC, type SyntheticEvent, useState } from 'react'; type ErrorFeedbackProps = { message: string; + onClose?: () => void; }; -export const ErrorFeedback: FC = ({ message }) => { +export const ErrorFeedback: FC = ({ message, onClose }) => { const [open, setOpen] = useState(true); const handleClose = (_?: SyntheticEvent | Event, reason?: string) => { @@ -13,6 +14,7 @@ export const ErrorFeedback: FC = ({ message }) => { return; } setOpen(false); + onClose?.(); }; const action = ( diff --git a/website/src/components/Submission/SubmissionForm.spec.tsx b/website/src/components/Submission/SubmissionForm.spec.tsx index e587cb0d6..fcd56f776 100644 --- a/website/src/components/Submission/SubmissionForm.spec.tsx +++ b/website/src/components/Submission/SubmissionForm.spec.tsx @@ -31,6 +31,7 @@ const testResponse: SubmissionIdMapping[] = [ describe('SubmitForm', () => { test('should handle file upload and server response', async () => { mockRequest.backend.submit(200, testResponse); + mockRequest.backend.getGroupsOfUser(); const { getByLabelText, getByText } = renderSubmissionForm(); @@ -48,6 +49,7 @@ describe('SubmitForm', () => { test('should answer with feedback that a file is missing', async () => { mockRequest.backend.submit(200, testResponse); + mockRequest.backend.getGroupsOfUser(); const { getByLabelText, getByText } = renderSubmissionForm(); @@ -57,14 +59,34 @@ describe('SubmitForm', () => { await userEvent.click(submitButton); await waitFor(() => { - expect( - getByText((text) => text.includes('Please select both a metadata and sequences file')), - ).toBeInTheDocument(); + expect(getByText((text) => text.includes('Please select a sequences file'))).toBeInTheDocument(); }); }); + test('should select a group if there is more than one', async () => { + mockRequest.backend.submit(200, testResponse); + mockRequest.backend.getGroupsOfUser(200, [{ groupName: 'Group1' }, { groupName: 'Group2' }]); + + const { getByRole } = renderSubmissionForm(); + + await waitFor(() => { + expect(getByRole('option', { name: 'Group2' })).toBeInTheDocument(); + expect(getByRole('option', { name: 'Group1' })).toBeInTheDocument(); + }); + }); + + test('should forbid submitting when there is no group', async () => { + mockRequest.backend.submit(200, testResponse); + mockRequest.backend.getGroupsOfUser(200, []); + + const { getByText } = renderSubmissionForm(); + + await waitFor(() => expect(getByText((text) => text.includes('No group found.'))).toBeInTheDocument()); + }); + test('should unexpected error with proper error message', async () => { mockRequest.backend.submit(500, 'a weird, unexpected test error'); + mockRequest.backend.getGroupsOfUser(); await submitAndExpectErrorMessageContains('Received unexpected message from backend'); }); @@ -78,6 +100,7 @@ describe('SubmitForm', () => { type: 'dummy type', }; mockRequest.backend.submit(422, problemDetail); + mockRequest.backend.getGroupsOfUser(); const expectedErrorMessage = `The submitted file content was invalid: ${problemDetail.detail}`; await submitAndExpectErrorMessageContains(expectedErrorMessage); diff --git a/website/src/components/User/GroupManager.tsx b/website/src/components/User/GroupManager.tsx index 026bbdfe5..1b2919c8d 100644 --- a/website/src/components/User/GroupManager.tsx +++ b/website/src/components/User/GroupManager.tsx @@ -1,90 +1,100 @@ -import React, { type FC, useRef, useState } from 'react'; +import { type FC, type FormEvent, useRef, useState } from 'react'; +import { useGroupManagerHooks } from '../../hooks/useGroupOperations.ts'; +import type { Group } from '../../types/backend.ts'; +import { type ClientConfig } from '../../types/runtimeConfig.ts'; import { ConfirmationDialog } from '../ConfirmationDialog.tsx'; +import { ErrorFeedback } from '../ErrorFeedback.tsx'; +import { withQueryProvider } from '../common/withQueryProvider.tsx'; import LeaveIcon from '~icons/pepicons-pop/leave-circle-filled'; -type Group = { - name: string; -}; - -interface GroupsProps { - initialGroups: Group[]; +interface GroupManagerProps { + clientConfig: ClientConfig; + accessToken: string; + username: string; } -export const GroupManager: FC = ({ initialGroups }) => { - const [groups, setGroups] = useState(initialGroups); - const [groupToDelete, setGroupToDelete] = useState(null); +const InnerGroupManager: FC = ({ clientConfig, accessToken, username }) => { + const [groupToLeave, setGroupToLeave] = useState(null); const [newGroupName, setNewGroupName] = useState(''); const dialogRef = useRef(null); - const handleCreateGroup = () => { - if (newGroupName.trim() !== '') { - const newGroup: Group = { - name: newGroupName, - }; + const [errorMessage, setErrorMessage] = useState(undefined); - setGroups([...groups, newGroup]); - setNewGroupName(''); - } + const { createGroup, leaveGroup, groupsOfUser } = useGroupManagerHooks({ + clientConfig, + accessToken, + setErrorMessage, + }); + + const handleCreateGroup = async (e: FormEvent) => { + e.preventDefault(); + await createGroup(newGroupName); + setNewGroupName(''); }; - const handleDeleteGroup = () => { - const updatedGroups = groups.filter((group) => group.name !== groupToDelete?.name); - setGroups(updatedGroups); + const handleLeaveGroup = async () => { + if (groupToLeave) { + await leaveGroup(groupToLeave.groupName, username); + setGroupToLeave(null); + } }; const handleOpenConfirmationDialog = (group: Group) => { - setGroupToDelete(group); + setGroupToLeave(group); if (dialogRef.current) { dialogRef.current.showModal(); } }; - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleCreateGroup(); - } - }; - return (

Groups

+ {errorMessage !== undefined && ( + setErrorMessage(undefined)} /> + )} + -
- setNewGroupName(e.target.value)} - onKeyDown={handleInputKeyDown} - placeholder='Enter new group name' - className='p-2 border border-gray-300 rounded mr-2' - /> - -
+ +
+ setNewGroupName(e.target.value.trim())} + placeholder='Enter new group name' + className='p-2 border border-gray-300 rounded mr-2' + required + /> + +
+
    - {groups.map((group) => ( -
  • - {group.name} - -
  • - ))} + {!groupsOfUser.isLoading && + groupsOfUser.data?.map((group) => ( +
  • + {group.groupName} + +
  • + ))}
); }; + +export const GroupManager = withQueryProvider(InnerGroupManager); diff --git a/website/src/components/User/GroupPage.tsx b/website/src/components/User/GroupPage.tsx index 496209982..6051b57bf 100644 --- a/website/src/components/User/GroupPage.tsx +++ b/website/src/components/User/GroupPage.tsx @@ -1,32 +1,46 @@ -import { type FC, useRef, useState } from 'react'; +import { type FC, type FormEvent, useRef, useState } from 'react'; +import { useGroupPageHooks } from '../../hooks/useGroupOperations.ts'; +import { type ClientConfig } from '../../types/runtimeConfig.ts'; import { ConfirmationDialog } from '../ConfirmationDialog.tsx'; +import { ErrorFeedback } from '../ErrorFeedback.tsx'; +import { withQueryProvider } from '../common/withQueryProvider.tsx'; import DeleteIcon from '~icons/ci/user-remove'; type User = { name: string; }; -interface GroupPageProps { - users: User[]; -} +type GroupPageProps = { + groupName: string; + clientConfig: ClientConfig; + accessToken: string; +}; -export const GroupPage: FC = ({ users }) => { +const InnerGroupPage: FC = ({ groupName, clientConfig, accessToken }) => { const [newUserName, setNewUserName] = useState(''); const [userToDelete, setUserToDelete] = useState(null); const dialogRef = useRef(null); - const handleAddUser = () => { - if (newUserName.trim() !== '') { - // TODO: Add logic to update the state or send the new user data to the server + const [errorMessage, setErrorMessage] = useState(undefined); - setNewUserName(''); - } + const { groupDetails, removeFromGroup, addUserToGroup } = useGroupPageHooks({ + clientConfig, + accessToken, + setErrorMessage, + groupName, + }); + + const handleAddUser = async (e: FormEvent) => { + e.preventDefault(); + await addUserToGroup(newUserName); + setNewUserName(''); }; - const handleDeleteUser = () => { + const handleDeleteUser = async () => { if (userToDelete) { - // TODO: Add logic to update the state or send the delete request to the server + await removeFromGroup(userToDelete.name); + setUserToDelete(null); } }; @@ -37,12 +51,6 @@ export const GroupPage: FC = ({ users }) => { } }; - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleAddUser(); - } - }; - return (
@@ -52,36 +60,46 @@ export const GroupPage: FC = ({ users }) => { /> -
- setNewUserName(e.target.value)} - onKeyDown={handleInputKeyDown} - placeholder='Enter new user name' - className='p-2 border border-gray-300 rounded mr-2' - /> - -
+ {errorMessage !== undefined && ( + setErrorMessage(undefined)} /> + )} + +
+
+ setNewUserName(e.target.value.trim())} + placeholder='Enter new user name' + className='p-2 border border-gray-300 rounded mr-2' + required + /> + +
+
    - {users.map((user) => ( -
  • - {user.name} - -
  • - ))} + {!groupDetails.isLoading && + groupDetails.data && + groupDetails.data.users.map((user) => ( +
  • + {user.name} + +
  • + ))}
); }; + +export const GroupPage = withQueryProvider(InnerGroupPage); diff --git a/website/src/components/User/UserPage.astro b/website/src/components/User/UserPage.astro index 65a52d1bf..5729061e7 100644 --- a/website/src/components/User/UserPage.astro +++ b/website/src/components/User/UserPage.astro @@ -1,11 +1,18 @@ --- import { GroupManager } from './GroupManager'; +import { getRuntimeConfig } from '../../config'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { getKeycloakClient } from '../../middleware/authMiddleware'; import { routes } from '../../routes'; +import { getAccessToken } from '../../utils/getAccessToken'; import { BackButton } from '../Navigation/BackButton'; const session = Astro.locals.session; + +const accessToken = getAccessToken(Astro.locals.session)!; + +const clientConfig = getRuntimeConfig().public; + const organism: string | undefined = Astro.params.organism; const logoutUrl = new URL(Astro.request.url); @@ -33,5 +40,14 @@ const keycloakLogoutUrl = (await getKeycloakClient()).endSessionUrl({ ) } - + { + session.user?.username !== undefined && ( + + ) + } diff --git a/website/src/env.d.ts b/website/src/env.d.ts index ec7db3a29..3b8977c27 100644 --- a/website/src/env.d.ts +++ b/website/src/env.d.ts @@ -9,6 +9,7 @@ type Session = { isLoggedIn: boolean; user?: { name?: string; + username?: string; }; token?: TokenCookie; }; diff --git a/website/src/hooks/useGroupOperations.ts b/website/src/hooks/useGroupOperations.ts new file mode 100644 index 000000000..d8163a0d3 --- /dev/null +++ b/website/src/hooks/useGroupOperations.ts @@ -0,0 +1,165 @@ +import { Zodios, type ZodiosInstance } from '@zodios/core'; +import { ZodiosHooks } from '@zodios/react'; +import { useCallback, useMemo } from 'react'; + +import { groupManagementApi } from '../services/groupManagementApi.ts'; +import type { ClientConfig } from '../types/runtimeConfig.ts'; +import { createAuthorizationHeader } from '../utils/createAuthorizationHeader.ts'; +import { stringifyMaybeAxiosError } from '../utils/stringifyMaybeAxiosError.ts'; + +type UseGroupOperationsProps = { + clientConfig: ClientConfig; + accessToken: string; + setErrorMessage: (message?: string) => void; +}; + +export const useGroupManagerHooks = ({ clientConfig, accessToken, setErrorMessage }: UseGroupOperationsProps) => { + const { zodios, zodiosHooks } = useGroupManagementClient(clientConfig); + + const groupsOfUser = zodiosHooks.useGetGroupsOfUser({ + headers: createAuthorizationHeader(accessToken), + }); + + if (groupsOfUser.error) { + setErrorMessage(`Failed to query Groups: ${stringifyMaybeAxiosError(groupsOfUser.error)}`); + } + + const createGroup = useCallback( + async (newGroupName: string) => { + await callCreateGroup(accessToken, setErrorMessage, groupsOfUser.refetch, zodios)(newGroupName); + }, + [accessToken, setErrorMessage, groupsOfUser.refetch, zodios], + ); + + const leaveGroup = useCallback( + async (groupName: string, username: string) => { + await callRemoveFromGroup(accessToken, setErrorMessage, groupsOfUser.refetch, zodios)(groupName, username); + }, + [accessToken, setErrorMessage, groupsOfUser.refetch, zodios], + ); + + return { + createGroup, + leaveGroup, + groupsOfUser, + }; +}; + +export const useGroupPageHooks = ({ + clientConfig, + accessToken, + setErrorMessage, + groupName, +}: UseGroupOperationsProps & { groupName: string }) => { + const { zodios, zodiosHooks } = useGroupManagementClient(clientConfig); + + const groupDetails = zodiosHooks.useGetUsersOfGroup({ + headers: createAuthorizationHeader(accessToken), + params: { + detailsOfGroupName: groupName, + }, + }); + + if (groupDetails.error) { + setErrorMessage(`Failed to query Group ${groupName}: ${stringifyMaybeAxiosError(groupDetails.error)}`); + } + + const addUserToGroup = useCallback( + async (username: string) => { + await callAddToGroup(accessToken, setErrorMessage, groupDetails.refetch, zodios)(groupName, username); + }, + [accessToken, setErrorMessage, groupDetails.refetch, zodios, groupName], + ); + + const removeFromGroup = useCallback( + async (username: string) => { + await callRemoveFromGroup(accessToken, setErrorMessage, groupDetails.refetch, zodios)(groupName, username); + }, + [accessToken, setErrorMessage, groupDetails.refetch, zodios, groupName], + ); + + return { + addUserToGroup, + removeFromGroup, + groupDetails, + }; +}; + +export const useGroupManagementClient = (clientConfig: ClientConfig) => { + const zodios = useMemo(() => new Zodios(clientConfig.backendUrl, groupManagementApi), [clientConfig]); + const zodiosHooks = useMemo(() => new ZodiosHooks('pathoplexus', zodios), [zodios]); + return { + zodios, + zodiosHooks, + }; +}; + +function callCreateGroup( + accessToken: string, + openErrorFeedback: (message: string | undefined) => void, + refetchGroups: () => void, + zodios: ZodiosInstance, +) { + return async (groupName: string) => { + try { + await zodios.createGroup( + { + groupName, + }, + { + headers: createAuthorizationHeader(accessToken), + }, + ); + refetchGroups(); + } catch (error) { + const message = `Failed to create group: ${stringifyMaybeAxiosError(error)}`; + openErrorFeedback(message); + } + }; +} + +function callRemoveFromGroup( + accessToken: string, + openErrorFeedback: (message: string | undefined) => void, + refetchGroups: () => void, + zodios: ZodiosInstance, +) { + return async (groupName: string, username: string) => { + try { + await zodios.removeUserFromGroup(undefined, { + headers: createAuthorizationHeader(accessToken), + params: { + groupName, + userToRemove: username, + }, + }); + refetchGroups(); + } catch (error) { + const message = `Failed to leave group: ${stringifyMaybeAxiosError(error)}`; + openErrorFeedback(message); + } + }; +} + +function callAddToGroup( + accessToken: string, + openErrorFeedback: (message: string | undefined) => void, + refetchGroups: () => void, + zodios: ZodiosInstance, +) { + return async (groupName: string, username: string) => { + try { + await zodios.addUserToGroup(undefined, { + headers: createAuthorizationHeader(accessToken), + params: { + groupName, + userToAdd: username, + }, + }); + refetchGroups(); + } catch (error) { + const message = `Failed to add user to group: ${stringifyMaybeAxiosError(error)}`; + openErrorFeedback(message); + } + }; +} diff --git a/website/src/middleware/authMiddleware.ts b/website/src/middleware/authMiddleware.ts index fa99f2115..c16bc29df 100644 --- a/website/src/middleware/authMiddleware.ts +++ b/website/src/middleware/authMiddleware.ts @@ -103,7 +103,10 @@ export const authMiddleware = defineMiddleware(async (context, next) => { context.locals.session = { isLoggedIn: true, - user: { name: userInfo.value.name ?? 'Username not set' }, + user: { + name: userInfo.value.name ?? 'Username not set', + username: userInfo.value.preferred_username, + }, token, }; diff --git a/website/src/pages/[organism]/group/[groupName]/index.astro b/website/src/pages/[organism]/group/[groupName]/index.astro new file mode 100644 index 000000000..8142a525d --- /dev/null +++ b/website/src/pages/[organism]/group/[groupName]/index.astro @@ -0,0 +1,19 @@ +--- +import { BackButton } from '../../../../components/Navigation/BackButton'; +import { GroupPage } from '../../../../components/User/GroupPage'; +import { getRuntimeConfig } from '../../../../config'; +import BaseLayout from '../../../../layouts/BaseLayout.astro'; +import { getAccessToken } from '../../../../utils/getAccessToken'; + +const accessToken = getAccessToken(Astro.locals.session)!; +const groupName = Astro.params.groupName!; +const clientConfig = getRuntimeConfig().public; +--- + + +
+ +

Group: {groupName}

+
+ +
diff --git a/website/src/pages/[organism]/group/index.astro b/website/src/pages/[organism]/group/index.astro deleted file mode 100644 index 0d821ac23..000000000 --- a/website/src/pages/[organism]/group/index.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -import { BackButton } from '../../../components/Navigation/BackButton'; -import { GroupPage } from '../../../components/User/GroupPage'; -import BaseLayout from '../../../layouts/BaseLayout.astro'; ---- - - -
- -

My super-duper mega-fancy Group

-
- -
diff --git a/website/src/services/backendApi.ts b/website/src/services/backendApi.ts index 6d69d5ab1..3769e8589 100644 --- a/website/src/services/backendApi.ts +++ b/website/src/services/backendApi.ts @@ -1,37 +1,19 @@ -import { makeApi, makeEndpoint, makeErrors, makeParameters } from '@zodios/core'; +import { makeApi, makeEndpoint } from '@zodios/core'; import z from 'zod'; +import { authorizationHeader, notAuthorizedError, withOrganismPathSegment } from './commonApiTypes.ts'; import { - sequenceEntryToEdit, accessions, accessionVersionsObject, problemDetail, sequenceEntryStatus, + sequenceEntryToEdit, submissionIdMapping, submitFiles, unprocessedData, uploadFiles, } from '../types/backend.ts'; -const [authorizationHeader] = makeParameters([ - { - name: 'Authorization', - type: 'Header', - schema: z.string().includes('Bearer ', { position: 0 }), - }, -]); - -function withOrganismPathSegment(path: Path) { - return `/:organism${path}` as const; -} - -const notAuthorizedError = makeErrors([ - { - status: 401, - schema: z.never(), - }, -])[0]; - const submitEndpoint = makeEndpoint({ method: 'post', path: withOrganismPathSegment('/submit'), @@ -206,42 +188,6 @@ const submitProcessedDataEndpoint = makeEndpoint({ errors: [{ status: 'default', schema: problemDetail }, { status: 422, schema: problemDetail }, notAuthorizedError], }); -const createGroupEndpoint = makeEndpoint({ - method: 'post', - path: '/groups', - alias: 'createGroup', - parameters: [ - authorizationHeader, - { - name: 'data', - type: 'Body', - schema: z.object({ - groupName: z.string(), - }), - }, - ], - response: z.never(), - errors: [notAuthorizedError], -}); - -const addUserToGroupEndpoint = makeEndpoint({ - method: 'post', - path: '/groups/:groupName/users', - alias: 'addUserToGroup', - parameters: [ - authorizationHeader, - { - name: 'data', - type: 'Body', - schema: z.object({ - username: z.string(), - }), - }, - ], - response: z.never(), - errors: [notAuthorizedError], -}); - export const backendApi = makeApi([ submitEndpoint, reviseEndpoint, @@ -254,6 +200,4 @@ export const backendApi = makeApi([ confirmRevocationEndpoint, extractUnprocessedDataEndpoint, submitProcessedDataEndpoint, - createGroupEndpoint, - addUserToGroupEndpoint, ]); diff --git a/website/src/services/commonApiTypes.ts b/website/src/services/commonApiTypes.ts new file mode 100644 index 000000000..f718ed805 --- /dev/null +++ b/website/src/services/commonApiTypes.ts @@ -0,0 +1,25 @@ +import { makeErrors, makeParameters } from '@zodios/core'; +import z from 'zod'; + +import { problemDetail } from '../types/backend.ts'; + +export const [authorizationHeader] = makeParameters([ + { + name: 'Authorization', + type: 'Header', + schema: z.string().includes('Bearer ', { position: 0 }), + }, +]); + +export function withOrganismPathSegment(path: Path) { + return `/:organism${path}` as const; +} + +export const notAuthorizedError = makeErrors([ + { + status: 401, + schema: z.never(), + }, +])[0]; + +export const conflictError = { status: 409, schema: problemDetail }; diff --git a/website/src/services/groupManagementApi.ts b/website/src/services/groupManagementApi.ts new file mode 100644 index 000000000..32956b016 --- /dev/null +++ b/website/src/services/groupManagementApi.ts @@ -0,0 +1,71 @@ +import { makeApi, makeEndpoint } from '@zodios/core'; +import z from 'zod'; + +import { authorizationHeader, conflictError, notAuthorizedError } from './commonApiTypes.ts'; +import { group, groupDetails } from '../types/backend.ts'; + +const createGroupEndpoint = makeEndpoint({ + method: 'post', + path: '/groups', + alias: 'createGroup', + parameters: [ + authorizationHeader, + { + name: 'data', + type: 'Body', + schema: z.object({ + groupName: z.string(), + }), + }, + ], + response: z.never(), + errors: [notAuthorizedError, conflictError], +}); +const addUserToGroupEndpoint = makeEndpoint({ + method: 'put', + path: '/groups/:groupName/users/:userToAdd', + alias: 'addUserToGroup', + parameters: [authorizationHeader], + response: z.never(), + errors: [notAuthorizedError, conflictError], +}); +const removeUserFromGroupEndpoint = makeEndpoint({ + method: 'delete', + path: '/groups/:groupName/users/:userToRemove', + alias: 'removeUserFromGroup', + parameters: [authorizationHeader], + response: z.never(), + errors: [notAuthorizedError], +}); +const getUsersOfGroupEndpoint = makeEndpoint({ + method: 'get', + path: '/groups/:detailsOfGroupName', + alias: 'getUsersOfGroup', + parameters: [authorizationHeader], + response: groupDetails, + errors: [notAuthorizedError], +}); +const getGroupsOfUserEndpoint = makeEndpoint({ + method: 'get', + path: '/user/groups', + alias: 'getGroupsOfUser', + parameters: [authorizationHeader], + response: z.array(group), + errors: [notAuthorizedError], +}); +const getAllGroupsEndpoint = makeEndpoint({ + method: 'get', + path: '/groups', + alias: 'getAllGroups', + parameters: [authorizationHeader], + response: z.array(group), + errors: [notAuthorizedError], +}); +export const groupManagementApi = makeApi([ + createGroupEndpoint, + addUserToGroupEndpoint, + removeUserFromGroupEndpoint, + getUsersOfGroupEndpoint, + getGroupsOfUserEndpoint, + getAllGroupsEndpoint, +]); diff --git a/website/src/services/groupManagementClient.ts b/website/src/services/groupManagementClient.ts new file mode 100644 index 000000000..ff34e8266 --- /dev/null +++ b/website/src/services/groupManagementClient.ts @@ -0,0 +1,19 @@ +import { groupManagementApi } from './groupManagementApi.ts'; +import { ZodiosWrapperClient } from './zodiosWrapperClient.ts'; +import { getRuntimeConfig } from '../config.ts'; +import { getInstanceLogger } from '../logger.ts'; + +export class GroupManagementClient extends ZodiosWrapperClient { + public static create( + backendUrl: string = getRuntimeConfig().serverSide.backendUrl, + logger = getInstanceLogger('serverSideBackendClient'), + ) { + return new GroupManagementClient( + backendUrl, + groupManagementApi, + (axiosError) => axiosError.data, + logger, + 'backend', + ); + } +} diff --git a/website/src/services/serviceHooks.ts b/website/src/services/serviceHooks.ts index f83df5198..685cafd73 100644 --- a/website/src/services/serviceHooks.ts +++ b/website/src/services/serviceHooks.ts @@ -2,6 +2,7 @@ import { Zodios } from '@zodios/core'; import { ZodiosHooks, type ZodiosHooksInstance } from '@zodios/react'; import { backendApi } from './backendApi.ts'; +import { groupManagementApi } from './groupManagementApi.ts'; import { lapisApi } from './lapisApi.ts'; import type { Schema } from '../types/config.ts'; import type { LapisBaseRequest } from '../types/lapis.ts'; @@ -13,6 +14,10 @@ export function backendClientHooks(clientConfig: ClientConfig) { return new ZodiosHooks('loculus', new Zodios(clientConfig.backendUrl, backendApi)); } +export function groupManagementClientHooks(clientConfig: ClientConfig) { + return new ZodiosHooks('pathoplexus', new Zodios(clientConfig.backendUrl, groupManagementApi)); +} + export function lapisClientHooks(lapisUrl: string) { const zodiosHooks = new ZodiosHooks('lapis', new Zodios(lapisUrl, lapisApi, { transform: false })); return { diff --git a/website/src/services/zodiosWrapperClient.ts b/website/src/services/zodiosWrapperClient.ts index 3a8b958a4..8e7cdb636 100644 --- a/website/src/services/zodiosWrapperClient.ts +++ b/website/src/services/zodiosWrapperClient.ts @@ -29,7 +29,7 @@ export class ZodiosWrapperClient { this.zodios = new Zodios(url, api); } - public call>( + public async call>( method: Method, ...args: ZodiosMethod['parameters'] ): Promise['response']>, ProblemDetail>> { @@ -42,11 +42,11 @@ export class ZodiosWrapperClient { return zodiosResponse.then( (response) => ok(response), async (error: AxiosError): Promise> => - err(await this.createProblemDetail(error, method)), + err(this.createProblemDetail(error, method)), ); } - private async createProblemDetail(error: AxiosError, method: string): Promise { + private createProblemDetail(error: AxiosError, method: string): ProblemDetail { if (error.response?.status === 401) { const message = error.response.headers['www-authenticate'] ?? 'Not authorized'; return { diff --git a/website/src/types/backend.ts b/website/src/types/backend.ts index 19fe755b3..6363915a1 100644 --- a/website/src/types/backend.ts +++ b/website/src/types/backend.ts @@ -111,3 +111,17 @@ export const problemDetail = z.object({ instance: z.string().optional(), }); export type ProblemDetail = z.infer; + +export const group = z.object({ + groupName: z.string(), +}); +export type Group = z.infer; + +export const groupDetails = z.object({ + groupName: z.string(), + users: z.array( + z.object({ + name: z.string(), + }), + ), +}); diff --git a/website/src/utils/stringifyMaybeAxiosError.ts b/website/src/utils/stringifyMaybeAxiosError.ts index 411142088..792d73062 100644 --- a/website/src/utils/stringifyMaybeAxiosError.ts +++ b/website/src/utils/stringifyMaybeAxiosError.ts @@ -1,5 +1,12 @@ import { type AxiosError } from 'axios'; -export function stringifyMaybeAxiosError(error: unknown | AxiosError) { - return error?.toString() ?? JSON.stringify(error); -} +import type { ProblemDetail } from '../types/backend.ts'; + +export const stringifyMaybeAxiosError = (error: unknown): string => { + const data = (error as AxiosError).response?.data; + if (typeof data === 'object' && data !== null) { + return (data as ProblemDetail).detail; + } + + return error?.toString() ?? JSON.stringify((error as Error).message); +}; diff --git a/website/tests/e2e.fixture.ts b/website/tests/e2e.fixture.ts index dc69b32dc..a938ebe7e 100644 --- a/website/tests/e2e.fixture.ts +++ b/website/tests/e2e.fixture.ts @@ -14,6 +14,7 @@ import { SubmitPage } from './pages/submit/submit.page'; import { UserPage } from './pages/user/user.page'; import { ACCESS_TOKEN_COOKIE, clientMetadata, realmPath, REFRESH_TOKEN_COOKIE } from '../src/middleware/authMiddleware'; import { BackendClient } from '../src/services/backendClient'; +import { GroupManagementClient } from '../src/services/groupManagementClient.ts'; type E2EFixture = { searchPage: SearchPage; @@ -40,6 +41,7 @@ export const e2eLogger = winston.createLogger({ }); export const backendClient = BackendClient.create(backendUrl, e2eLogger); +export const groupManagementClient = GroupManagementClient.create(backendUrl, e2eLogger); export const testSequenceEntry = { name: '1.1', diff --git a/website/tests/playwrightSetup.ts b/website/tests/playwrightSetup.ts index c9e5773a6..c949d03bd 100644 --- a/website/tests/playwrightSetup.ts +++ b/website/tests/playwrightSetup.ts @@ -1,9 +1,11 @@ +import { isErrorFromAlias } from '@zodios/core'; import isEqual from 'lodash/isEqual.js'; import sortBy from 'lodash/sortBy.js'; import { e2eLogger, getToken, lapisUrl, testUser, testUserPassword } from './e2e.fixture.ts'; import { addUserToGroup, createGroup } from './util/backendCalls.ts'; import { prepareDataToBe } from './util/prepareDataToBe.ts'; +import { groupManagementApi } from '../src/services/groupManagementApi.ts'; import { LapisClient } from '../src/services/lapisClient.ts'; import { ACCESSION_FIELD, IS_REVOCATION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD } from '../src/settings.ts'; import { siloVersionStatuses } from '../src/types/lapis.ts'; @@ -19,6 +21,12 @@ export default async function globalSetupForPlaywright() { const secondsToWait = 10; const maxNumberOfRetries = 12; + e2eLogger.info(`logging in as '${testUser}' + playwright users. Setup testGroups.`); + const token = (await getToken(testUser, testUserPassword)).accessToken; + + await createTestGroupIfNotExistent(token); + await addTestuserToTestGroupIfNotExistent(token); + const lapisClient = LapisClient.create( lapisUrl, { metadata: [], instanceName: 'Test', primaryKey: 'doesNotMatter', tableColumns: [] }, @@ -36,12 +44,6 @@ export default async function globalSetupForPlaywright() { e2eLogger.info('No sequences found in LAPIS. Generate data for tests.'); - e2eLogger.info(`logging in as '${testUser}'.`); - const token = (await getToken(testUser, testUserPassword)).accessToken; - - await createTestGroupIfNotExistent(token); - await addTestuserToTestGroupIfNotExistent(token); - e2eLogger.info('preparing data in backend.'); const data = await prepareDataToBe('approvedForRelease', token); const revokedData = await prepareDataToBe('revoked', token); @@ -76,8 +78,9 @@ function waitSeconds(seconds: number) { } async function checkLapisState(lapisClient: LapisClient): Promise { - const numberOfSequencesInLapis = (await lapisClient.call('aggregated', {}))._unsafeUnwrap().data[0].count; - if (numberOfSequencesInLapis === 0) { + const numberOfSequencesInLapisResult = await lapisClient.call('aggregated', {}); + + if (numberOfSequencesInLapisResult._unsafeUnwrap().data[0].count === 0) { return LapisStateBeforeTests.NoSequencesInLapis; } @@ -168,7 +171,9 @@ async function addTestuserToTestGroupIfNotExistent(token: string) { try { await addUserToGroup(DEFAULT_GROUP_NAME, `testuser_${i}_${browser}`, token); } catch (error) { - if (!(error as Error).message.includes(' is already member of the group')) { + const groupDoesAlreadyExist = + isErrorFromAlias(groupManagementApi, 'addUserToGroup', error) && error.response.status === 409; + if (!groupDoesAlreadyExist) { throw error; } } @@ -180,7 +185,9 @@ async function createTestGroupIfNotExistent(token: string) { try { await createGroup(DEFAULT_GROUP_NAME, token); } catch (error) { - if (!(error as Error).message.includes('Group name already exists')) { + const groupDoesAlreadyExist = + isErrorFromAlias(groupManagementApi, 'createGroup', error) && error.response.status === 409; + if (!groupDoesAlreadyExist) { throw error; } } diff --git a/website/tests/util/backendCalls.ts b/website/tests/util/backendCalls.ts index 52fc7c634..97f1496de 100644 --- a/website/tests/util/backendCalls.ts +++ b/website/tests/util/backendCalls.ts @@ -1,7 +1,7 @@ import { createFileContent, createModifiedFileContent } from './createFileContent.ts'; import type { Accession, AccessionVersion } from '../../src/types/backend.ts'; import { createAuthorizationHeader } from '../../src/utils/createAuthorizationHeader.ts'; -import { backendClient, dummyOrganism, testSequenceCount } from '../e2e.fixture.ts'; +import { backendClient, dummyOrganism, groupManagementClient, testSequenceCount } from '../e2e.fixture.ts'; import { DEFAULT_GROUP_NAME } from '../playwrightSetup.ts'; export const submitViaApi = async (numberOfSequenceEntries: number = testSequenceCount, token: string) => { @@ -95,32 +95,17 @@ export const revokeReleasedData = async (accessions: Accession[], token: string) }; export const createGroup = async (newGroupName: string = DEFAULT_GROUP_NAME, token: string) => { - const response = await backendClient.call( - 'createGroup', + await groupManagementClient.zodios.createGroup( { groupName: newGroupName }, { headers: createAuthorizationHeader(token), }, ); - - if (response.isOk()) { - return; - } - throw new Error(JSON.stringify(response.error)); }; export const addUserToGroup = async (groupName: string = DEFAULT_GROUP_NAME, usernameToAdd: string, token: string) => { - const response = await backendClient.call( - 'addUserToGroup', - { username: usernameToAdd }, - { - params: { groupName }, - headers: createAuthorizationHeader(token), - }, - ); - - if (response.isOk()) { - return; - } - throw new Error(JSON.stringify(response.error)); + await groupManagementClient.zodios.addUserToGroup(undefined, { + params: { groupName, userToAdd: usernameToAdd }, + headers: createAuthorizationHeader(token), + }); }; diff --git a/website/vitest.setup.ts b/website/vitest.setup.ts index 940dbb90f..fc2408be5 100755 --- a/website/vitest.setup.ts +++ b/website/vitest.setup.ts @@ -9,6 +9,7 @@ import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; import type { SubmissionIdMapping } from './src/types/backend.ts'; import type { DetailsResponse, InsertionsResponse, LapisError, MutationsResponse } from './src/types/lapis.ts'; import type { RuntimeConfig } from './src/types/runtimeConfig.ts'; +import { DEFAULT_GROUP_NAME } from './tests/playwrightSetup.ts'; export const testConfig = { public: { @@ -44,6 +45,15 @@ const backendRequestMocks = { }), ); }, + getGroupsOfUser: (statusCode: number = 200, response: any = [{ groupName: DEFAULT_GROUP_NAME }]) => { + testServer.use( + http.get(`${testConfig.serverSide.backendUrl}/user/groups`, () => { + return new Response(JSON.stringify(response), { + status: statusCode, + }); + }), + ); + }, }; const lapisRequestMocks = {