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`] = ` >
+
+
+
+
+
+
+ Actions
+
+ |
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 ( |
---|