diff --git a/cypress/e2e/users.cy.ts b/cypress/e2e/users.cy.ts index a09c8d0c..5778f6a9 100644 --- a/cypress/e2e/users.cy.ts +++ b/cypress/e2e/users.cy.ts @@ -139,4 +139,88 @@ describe('Users', () => { cy.findByLabelText('Password').should('have.attr', 'type', 'password'); }); }); + + describe('change password', () => { + beforeEach(() => { + cy.visit('/admin/users'); + cy.findAllByRole('button', { name: 'Row Actions' }).first().click(); + cy.findByText('Change Password').click(); + }); + + afterEach(() => { + cy.clearMocks(); + }); + + it('displays error when no password is supplied', () => { + cy.findByText('Submit').click(); + cy.findByText( + 'Password field is empty. Please enter a new password or close the dialog.' + ).should('be.visible'); + }); + + it('update user password', () => { + cy.findByLabelText('Password').type('secure_password'); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Submit' }).click(); + + cy.findBrowserMockedRequests({ method: 'PATCH', url: '/users' }).should( + async (patchRequests) => { + expect(patchRequests.length).equal(1); + const request = patchRequests[0]; + expect(JSON.stringify(await request.json())).equal( + JSON.stringify({ + _id: 'user1', + updated_password: 'secure_password', + }) + ); + } + ); + }); + }); + + describe('modify authorised routes ', () => { + beforeEach(() => { + cy.visit('/admin/users'); + cy.findAllByRole('button', { name: 'Row Actions' }).first().click(); + cy.findByText('Modify Authorised Routes').click(); + }); + + afterEach(() => { + cy.clearMocks(); + }); + + it('displays error when no routes are changed', () => { + cy.findByText('Submit').click(); + cy.findByText( + 'Please modify the routes; these routes have not been edited.' + ).should('be.visible'); + }); + + it('modifies authorised routes', () => { + cy.findByRole('combobox').click(); + cy.findByRole('option', { name: '/submit/hdf POST' }).click(); + cy.findAllByRole('combobox').last().click(); + cy.findByRole('option', { name: '/users PATCH' }).click(); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Submit' }).click(); + + cy.findBrowserMockedRequests({ method: 'PATCH', url: '/users' }).should( + async (patchRequests) => { + expect(patchRequests.length).equal(1); + const request = patchRequests[0]; + expect(JSON.stringify(await request.json())).equal( + JSON.stringify({ + _id: 'user1', + add_authorised_routes: ['/users PATCH'], + remove_authorised_routes: ['/submit/hdf POST'], + }) + ); + } + ); + }); + }); }); diff --git a/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap b/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap index bae1510c..5d68c629 100644 --- a/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap +++ b/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap @@ -243,7 +243,7 @@ exports[`UsersTable Snapshot > matches snapshot 1`] = ` > matches snapshot 1`] = ` +
+
+ +
+
+
matches snapshot 1`] = ` class="MuiTableCell-root MuiTableCell-head MuiTableCell-stickyHeader MuiTableCell-alignLeft MuiTableCell-sizeMedium css-3651zu-MuiTableCell-root" colspan="1" data-can-sort="true" - data-index="1" + data-index="2" scope="col" >
matches snapshot 1`] = ` class="MuiTableCell-root MuiTableCell-head MuiTableCell-stickyHeader MuiTableCell-alignLeft MuiTableCell-sizeMedium css-1hnlvkg-MuiTableCell-root" colspan="1" data-can-sort="true" - data-index="2" + data-index="3" scope="col" >
matches snapshot 1`] = ` >

{ expect(passwordField).toHaveAttribute('type', 'password'); }); }); + describe('change password', () => { + let axiosPatchSpy: MockInstance; + + beforeEach(() => { + props.passwordOnly = true; + props.selectedUser = UsersJson[0]; + props.requestType = 'patch'; + axiosPatchSpy = vi.spyOn(axios, 'patch'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the component correctly', async () => { + createView(); + expect(screen.getByText('Change Password')).toBeInTheDocument(); + }); + + it('displays error when no password is supplied', async () => { + createView(); + await user.click(screen.getByText('Submit')); + expect( + await screen.findByText( + 'Password field is empty. Please enter a new password or close the dialog.' + ) + ).toBeInTheDocument(); + }); + + it('changes password successfully', async () => { + createView(); + + await user.type(screen.getByLabelText('Password'), 'secure_password'); + + await user.click(screen.getByText('Submit')); + + expect(axiosPatchSpy).toHaveBeenCalledWith( + '/users', + { + _id: 'user1', + updated_password: 'secure_password', + }, + { + headers: { + Authorization: 'Bearer null', + }, + } + ); + }); + }); + describe('modify authorised routes', () => { + let axiosPatchSpy: MockInstance; + + beforeEach(() => { + props.authorisedRoutesOnly = true; + props.selectedUser = UsersJson[0]; + props.requestType = 'patch'; + axiosPatchSpy = vi.spyOn(axios, 'patch'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the component correctly', async () => { + createView(); + expect(screen.getByText('Modify Authorised Routes')).toBeInTheDocument(); + }); + + it('displays error when no routes are changed', async () => { + createView(); + await user.click(screen.getByText('Submit')); + expect( + await screen.findByText( + 'Please modify the routes; these routes have not been edited.' + ) + ).toBeInTheDocument(); + }); + + it('modify authorised routes successfully', async () => { + createView(); + + const routes = screen.getByRole('combobox'); + + await user.click(routes); + await user.click( + await screen.findByRole('option', { name: '/submit/hdf POST' }) + ); + await user.click(routes); + await user.click( + await screen.findByRole('option', { name: '/users PATCH' }) + ); + + await user.click(screen.getByText('Submit')); + + expect(axiosPatchSpy).toHaveBeenCalledWith( + '/users', + { + _id: 'user1', + add_authorised_routes: ['/users PATCH'], + remove_authorised_routes: ['/submit/hdf POST'], + }, + { + headers: { + Authorization: 'Bearer null', + }, + } + ); + }); + }); }); diff --git a/src/admin/users/userDialogue.component.tsx b/src/admin/users/userDialogue.component.tsx index 05f07cbd..6eb79a8b 100644 --- a/src/admin/users/userDialogue.component.tsx +++ b/src/admin/users/userDialogue.component.tsx @@ -16,14 +16,17 @@ import { AxiosError } from 'axios'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; -import { useAddUser } from '../../api/user'; -import { APIError, User, type UserPost } from '../../app.types'; +import { useAddUser, useEditUser } from '../../api/user'; +import { APIError, UserPatch, UserPost, type User } from '../../app.types'; import { AUTH_TYPE_LIST, AUTHORISED_ROUTE_LIST } from './usersTable.component'; export interface UserDialogueProps { onClose: () => void; open: boolean; requestType: 'post' | 'patch'; + selectedUser?: User; + passwordOnly?: boolean; + authorisedRoutesOnly?: boolean; } interface BaseZodSchemaProps { @@ -52,18 +55,35 @@ const userSchema = z.object({ authorised_routes: z .array(z.string()) .transform((val) => (val.length === 0 || !val ? undefined : val)) + .nullable() .optional(), }); const UserDialogue = (props: UserDialogueProps) => { - const { open, onClose, requestType } = props; + const { + open, + onClose, + requestType, + selectedUser, + passwordOnly, + authorisedRoutesOnly, + } = props; - const initialUser: UserPost = { - _id: '', - sha256_password: '', - auth_type: 'local', - authorised_routes: [], - }; + const isNotAdding = requestType !== 'post' && selectedUser; + + const initialUser: UserPost = React.useMemo( + () => + isNotAdding + ? { ...selectedUser, sha256_password: '' } + : { + _id: '', + sha256_password: '', + auth_type: 'local', + authorised_routes: [], + }, + + [isNotAdding, selectedUser] + ); const { control, @@ -93,8 +113,9 @@ const UserDialogue = (props: UserDialogueProps) => { }, [clearErrors, onClose, reset]); const { mutateAsync: addUser, isPending: isAddPending } = useAddUser(); + const { mutateAsync: editUser, isPending: isEditPending } = useEditUser(); const handleAddUser = React.useCallback( - async (user: User) => { + async (user: UserPost) => { addUser(user) .then(() => handleClose()) .catch((error: AxiosError) => { @@ -120,6 +141,59 @@ const UserDialogue = (props: UserDialogueProps) => { [addUser, handleClose, setError] ); + const handleEditUser = React.useCallback( + async (user: UserPost) => { + if (!selectedUser) return; + + const patchUsers: UserPatch = { _id: selectedUser._id }; + + if (passwordOnly && !user.sha256_password) { + setError('sha256_password', { + message: + 'Password field is empty. Please enter a new password or close the dialog.', + }); + return; + } + + if (passwordOnly) { + patchUsers.updated_password = user.sha256_password!; + } + + if (authorisedRoutesOnly) { + const addedRoutes = + user.authorised_routes?.filter( + (route) => !selectedUser.authorised_routes?.includes(route) + ) ?? []; + + const removedRoutes = + selectedUser.authorised_routes?.filter( + (route) => !user.authorised_routes?.includes(route) + ) ?? []; + + if (addedRoutes.length === 0 && removedRoutes.length === 0) { + setError('authorised_routes', { + message: + 'Please modify the routes; these routes have not been edited.', + }); + return; + } + + patchUsers.add_authorised_routes = addedRoutes; + patchUsers.remove_authorised_routes = removedRoutes; + } + + editUser(patchUsers).then(() => handleClose()); + }, + [ + authorisedRoutesOnly, + editUser, + handleClose, + passwordOnly, + selectedUser, + setError, + ] + ); + const onSubmit = (data: UserPost) => { const newData: UserPost = { ...data, @@ -128,7 +202,11 @@ const UserDialogue = (props: UserDialogueProps) => { }), }; - if (requestType === 'post') handleAddUser(newData); + if (requestType === 'post') { + handleAddUser(newData); + } else { + handleEditUser(newData); + } }; const [showPassword, setShowPassword] = React.useState(false); @@ -137,104 +215,120 @@ const UserDialogue = (props: UserDialogueProps) => { setShowPassword(!showPassword); }; + const title = passwordOnly + ? 'Change Password' + : authorisedRoutesOnly + ? 'Modify Authorised Routes' + : 'Add User'; + return (

- Add User + {title} - ( - option} - onChange={(_, value) => field.onChange(value)} - renderInput={(params) => ( - - )} - /> - )} - /> - - - {userFormData.auth_type === 'local' && ( + {requestType !== 'patch' && !passwordOnly && !authorisedRoutesOnly && ( + ( + option} + onChange={(_, value) => field.onChange(value)} + renderInput={(params) => ( + + )} + /> + )} + /> + )} + {(passwordOnly || requestType === 'post') && ( - {showPassword ? : } - - ), - }} + disabled={requestType === 'patch'} + autoComplete="new-password" margin="dense" - error={!!errors.sha256_password} - helperText={errors.sha256_password?.message} + error={!!errors._id} + helperText={errors._id?.message} /> )} - ( - option} - onChange={(_, value) => field.onChange(value)} - renderInput={(params) => ( - - )} + {userFormData.auth_type === 'local' && + (passwordOnly || requestType === 'post') && ( + + {showPassword ? : } + + ), + }} + margin="dense" + error={!!errors.sha256_password} + helperText={errors.sha256_password?.message} /> )} - /> + {(authorisedRoutesOnly || requestType === 'post') && ( + ( + option} + onChange={(_, value) => field.onChange(value)} + renderInput={(params) => ( + + )} + /> + )} + /> + )} ), + renderRowActionMenuItems: ({ closeMenu, row }) => { + return [ + { + setRequestType('patchAuthorisedRoutes'); + setSelectedUser(row.original); + table.setCreatingRow(true); + closeMenu(); + }} + sx={{ m: 0 }} + > + + + + Modify Authorised Routes + , + ...(row.original.auth_type === 'local' + ? [ + { + setRequestType('patchPassword'); + setSelectedUser(row.original); + table.setCreatingRow(true); + closeMenu(); + }} + sx={{ m: 0 }} + > + + + + Change Password + , + ] + : []), + ]; + }, }); return ; } diff --git a/src/api/user.test.tsx b/src/api/user.test.tsx index 006814cf..8078b7c8 100644 --- a/src/api/user.test.tsx +++ b/src/api/user.test.tsx @@ -2,7 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { User } from '../app.types'; import usersJson from '../mocks/users.json'; import { hooksWrapperWithProviders } from '../testUtils'; -import { useAddUser, useUsers } from './user'; +import { useAddUser, useEditUser, useUsers } from './user'; describe('useUsers', () => { it('sends request to fetch users and returns successful response', async () => { @@ -39,6 +39,32 @@ describe('useAddUser', () => { }); it.todo( - 'sends axios request to post user session and throws an appropriate error on failure' + 'sends axios request to add a user and throws an appropriate error on failure' + ); +}); + +describe('useEditUser', () => { + it('patches a request to edit a user and returns successful response', async () => { + const { result } = renderHook(() => useEditUser(), { + wrapper: hooksWrapperWithProviders(), + }); + expect(result.current.isIdle).toBe(true); + + result.current.mutate({ + _id: 'test', + updated_password: 'test', + add_authorised_routes: ['/submit/hdf POST'], + remove_authorised_routes: ['/submit/manifest POST'], + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(result.current.data).toEqual('test'); + }); + + it.todo( + 'sends axios request to edit a user and throws an appropriate error on failure' ); }); diff --git a/src/api/user.tsx b/src/api/user.tsx index ebf4882c..adeb187b 100644 --- a/src/api/user.tsx +++ b/src/api/user.tsx @@ -6,7 +6,7 @@ import { useQueryClient, } from '@tanstack/react-query'; import axios, { AxiosError } from 'axios'; -import { User, type UserPost } from '../app.types'; +import { User, UserPatch, UserPost } from '../app.types'; import { readSciGatewayToken } from '../parseTokens'; import { useAppSelector } from '../state/hooks'; import { selectUrls } from '../state/slices/configSlice'; @@ -61,3 +61,31 @@ export const useAddUser = (): UseMutationResult< }, }); }; + +const editUser = (apiUrl: string, user: UserPatch): Promise => { + return axios + .patch(`${apiUrl}/users`, user, { + headers: { + Authorization: `Bearer ${readSciGatewayToken()}`, + }, + }) + .then((response) => response.data); +}; + +export const useEditUser = (): UseMutationResult< + string, + AxiosError, + UserPatch +> => { + const { apiUrl } = useAppSelector(selectUrls); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (user: UserPatch) => editUser(apiUrl, user), + onError: (error) => { + console.log('Got error ' + error.message); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['Users'] }); + }, + }); +}; diff --git a/src/app.types.tsx b/src/app.types.tsx index f4c47dd7..9b93b39c 100644 --- a/src/app.types.tsx +++ b/src/app.types.tsx @@ -318,6 +318,13 @@ export interface User { authorised_routes?: string[] | null; } +export interface UserPatch { + _id: string; // Maps the `username` field in Python, which has an alias "_id" + updated_password?: string | null; + add_authorised_routes?: string[] | null; + remove_authorised_routes?: string[] | null; +} + export interface UserPost { _id: string; // Maps the `username` field in Python, which has an alias "_id" auth_type: string; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 5c9b6071..d3aa0a47 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -412,6 +412,11 @@ export const handlers = [ return HttpResponse.json(body._id, { status: 201 }); }), + http.patch('/users', async ({ request }) => { + const body = (await request.json()) as UserPost; + return HttpResponse.json(body._id, { status: 201 }); + }), + http.post('/users/filters', async () => { return HttpResponse.json('1', { status: 201 }); }),