diff --git a/cypress/e2e/users.cy.ts b/cypress/e2e/users.cy.ts
index e3d2298e..a09c8d0c 100644
--- a/cypress/e2e/users.cy.ts
+++ b/cypress/e2e/users.cy.ts
@@ -16,4 +16,127 @@ describe('Users', () => {
cy.findByRole('link', { name: 'home page' }).should('exist');
});
+
+ describe('add dialog', () => {
+ beforeEach(() => {
+ cy.visit('/admin/users');
+ cy.findByRole('button', { name: 'Add User' }).click();
+ });
+
+ afterEach(() => {
+ cy.clearMocks();
+ });
+
+ it('displays required error when username is empty', () => {
+ cy.findByRole('button', { name: 'Submit' }).click();
+ cy.findByText('Username is required.').should('exist');
+ });
+
+ it('displays password field only when auth_type is "local"', () => {
+ cy.findByLabelText('Username').type('new_user');
+ cy.findByLabelText('Password').should('exist');
+ });
+
+ it('dose not displays password field only when auth_type is "FedID"', () => {
+ cy.findAllByRole('combobox').first().click();
+ cy.findByRole('option', { name: 'FedID' }).click();
+
+ cy.findByLabelText('Username').type('new_user');
+ cy.findByLabelText('Password').should('not.exist');
+ });
+
+ it('adds user successfully (local)', () => {
+ cy.findByLabelText('Username').type('new_user');
+ cy.findByLabelText('Password').type('secure_password');
+
+ cy.findAllByRole('combobox').last().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: 'POST', url: '/users' }).should(
+ async (postRequests) => {
+ expect(postRequests.length).equal(1);
+ const request = postRequests[0];
+ expect(JSON.stringify(await request.json())).equal(
+ JSON.stringify({
+ _id: 'new_user',
+ sha256_password: 'secure_password',
+ auth_type: 'local',
+ authorised_routes: ['/submit/hdf POST', '/users PATCH'],
+ })
+ );
+ }
+ );
+ });
+
+ it('adds user successfully (fedId)', () => {
+ cy.findByLabelText('Username').type('new_user');
+
+ cy.findAllByRole('combobox').first().click();
+ cy.findByRole('option', { name: 'FedID' }).click();
+
+ cy.startSnoopingBrowserMockedRequest();
+
+ cy.findByRole('button', { name: 'Submit' }).click();
+
+ cy.findBrowserMockedRequests({ method: 'POST', url: '/users' }).should(
+ async (postRequests) => {
+ expect(postRequests.length).equal(1);
+ const request = postRequests[0];
+ expect(JSON.stringify(await request.json())).equal(
+ JSON.stringify({
+ _id: 'new_user',
+ auth_type: 'FedID',
+ })
+ );
+ }
+ );
+ });
+
+ it('displays error when adding a user without a password for "local" auth_type', () => {
+ cy.findByLabelText('Username').type('local_user');
+ cy.findByRole('button', { name: 'Submit' }).click();
+ cy.findByText(
+ 'for the auth_type you put (local), a password is required. Please add this field'
+ ).should('exist');
+ });
+
+ it('displays error for duplicate username', () => {
+ cy.findByLabelText('Username').type('test_dup');
+ cy.findByLabelText('Password').type('secure_password');
+ cy.findByRole('button', { name: 'Submit' }).click();
+ cy.findByText(
+ 'username field must not be the same as a pre existing user. You put: test_dup'
+ ).should('exist');
+ });
+
+ it('displays general error for unknown issues', () => {
+ cy.findByLabelText('Username').type('error');
+ cy.findByLabelText('Password').type('secure_password');
+ cy.findByRole('button', { name: 'Submit' }).click();
+ cy.findByText(
+ 'An unexpected error occurred. Please try again later.'
+ ).should('exist');
+ });
+
+ it('should show and hide password when clicking the visibility toggle', () => {
+ cy.findByLabelText('Username').type('testuser');
+ cy.findByLabelText('Password').type('secure_password');
+
+ cy.findByLabelText('Password').should('have.attr', 'type', 'password');
+
+ cy.findByLabelText('Show password').click();
+
+ cy.findByLabelText('Password').should('have.attr', 'type', 'text');
+
+ cy.findByLabelText('Hide password').click();
+
+ cy.findByLabelText('Password').should('have.attr', 'type', 'password');
+ });
+ });
});
diff --git a/package.json b/package.json
index 1c42edd0..191749bf 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@hello-pangea/dnd": "17.0.0",
+ "@hookform/resolvers": "^3.9.1",
"@mui/base": "5.0.0-beta.40",
"@mui/icons-material": "5.16.2",
"@mui/material": "5.16.2",
@@ -37,11 +38,13 @@
"react": "18.3.1",
"react-colorful": "5.6.1",
"react-dom": "18.3.1",
+ "react-hook-form": "^7.53.2",
"react-redux": "9.2.0",
"react-router-dom": "7.1.1",
"single-spa-react": "6.0.2",
"typescript": "5.6.2",
- "vite": "5.4.11"
+ "vite": "5.4.11",
+ "zod": "^3.23.8"
},
"resolutions": {
"nwsapi": "2.2.13",
diff --git a/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap b/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap
index f110a457..bae1510c 100644
--- a/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap
+++ b/src/admin/users/__snapshots__/usersTable.component.test.tsx.snap
@@ -19,9 +19,60 @@ exports[`UsersTable Snapshot > matches snapshot 1`] = `
-
+
+
+
+
@@ -217,15 +268,15 @@ exports[`UsersTable Snapshot > matches snapshot 1`] = `
- User ID
+ Username
matches snapshot 1`] = `
>
diff --git a/src/admin/users/userDialogue.component.test.tsx b/src/admin/users/userDialogue.component.test.tsx
new file mode 100644
index 00000000..c3e5636d
--- /dev/null
+++ b/src/admin/users/userDialogue.component.test.tsx
@@ -0,0 +1,210 @@
+import { screen } from '@testing-library/react';
+import userEvent, { UserEvent } from '@testing-library/user-event';
+import axios from 'axios';
+import { MockInstance } from 'vitest';
+import { renderComponentWithProviders } from '../../testUtils';
+import UserDialogue, { UserDialogueProps } from './userDialogue.component';
+
+describe('userDialogue', () => {
+ let props: UserDialogueProps;
+ let user: UserEvent;
+
+ const onClose = vi.fn();
+ const createView = () => {
+ return renderComponentWithProviders();
+ };
+
+ beforeEach(() => {
+ props = { onClose: onClose, open: true, requestType: 'post' };
+ user = userEvent.setup();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+ describe('add dialog', () => {
+ let axiosPostSpy: MockInstance;
+
+ beforeEach(() => {
+ axiosPostSpy = vi.spyOn(axios, 'post');
+ });
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the component correctly', async () => {
+ createView();
+ expect(screen.getByText('Add User')).toBeInTheDocument();
+ });
+
+ it('displays required error when username is empty', async () => {
+ createView();
+ const submitButton = screen.getByText('Submit');
+ await user.click(submitButton);
+
+ expect(
+ await screen.findByText('Username is required.')
+ ).toBeInTheDocument();
+ });
+
+ it('displays password field only when auth_type is "local"', async () => {
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'new_user');
+ expect(screen.getByLabelText('Password')).toBeInTheDocument();
+ });
+
+ it('does not displays password field only when auth_type is "local"', async () => {
+ createView();
+ await user.type(screen.getByLabelText('Username'), 'new_user');
+ const [authType, _routes] = screen.getAllByRole('combobox');
+ await user.click(authType);
+ await user.click(await screen.findByText('FedID'));
+ expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
+ });
+
+ it('adds user successfully (local)', async () => {
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'new_user');
+ await user.type(screen.getByLabelText('Password'), 'secure_password');
+
+ const [_authType, routes] = screen.getAllByRole('combobox');
+
+ await user.click(routes);
+ await user.click(await screen.findByText('/submit/hdf POST'));
+ await user.click(routes);
+ await user.click(await screen.findByText('/users PATCH'));
+
+ await user.click(screen.getByText('Submit'));
+
+ expect(axiosPostSpy).toHaveBeenCalledWith(
+ '/users',
+ {
+ _id: 'new_user',
+ auth_type: 'local',
+ authorised_routes: ['/submit/hdf POST', '/users PATCH'],
+ sha256_password: 'secure_password',
+ },
+ {
+ headers: {
+ Authorization: 'Bearer null',
+ },
+ }
+ );
+ });
+
+ it('adds user successfully (fedId)', async () => {
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'new_user');
+
+ const [authType, _routes] = screen.getAllByRole('combobox');
+
+ await user.click(authType);
+ await user.click(await screen.findByText('FedID'));
+
+ await user.click(screen.getByText('Submit'));
+
+ expect(axiosPostSpy).toHaveBeenCalledWith(
+ '/users',
+ {
+ _id: 'new_user',
+ auth_type: 'FedID',
+ },
+ {
+ headers: {
+ Authorization: 'Bearer null',
+ },
+ }
+ );
+ });
+
+ it('adds user successfully (fedId) switch from local to fedId', async () => {
+ // This test that the password us removed if you switch from local to fedId
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'new_user');
+ await user.type(screen.getByLabelText('Password'), 'secure_password');
+
+ const [authType, _routes] = screen.getAllByRole('combobox');
+
+ await user.click(authType);
+ await user.click(await screen.findByText('FedID'));
+
+ await user.click(screen.getByText('Submit'));
+
+ expect(axiosPostSpy).toHaveBeenCalledWith(
+ '/users',
+ {
+ _id: 'new_user',
+ auth_type: 'FedID',
+ },
+ {
+ headers: {
+ Authorization: 'Bearer null',
+ },
+ }
+ );
+ });
+
+ it('displays error when adding a user without a password for "local" auth_type', async () => {
+ createView();
+ await user.type(screen.getByLabelText('Username'), 'local_user');
+ await user.click(screen.getByText('Submit'));
+ expect(
+ await screen.findByText(
+ 'for the auth_type you put (local), a password is required. Please add this field'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('displays error for duplicate username', async () => {
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'test_dup');
+ await user.type(screen.getByLabelText('Password'), 'secure_password');
+ await user.click(screen.getByText('Submit'));
+
+ expect(
+ await screen.findByText(
+ 'username field must not be the same as a pre existing user. You put: test_dup'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('displays general error for unknown issues', async () => {
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'error');
+ await user.type(screen.getByLabelText('Password'), 'secure_password');
+ await user.click(screen.getByText('Submit'));
+
+ expect(
+ await screen.findByText(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('should show and hide password when clicking the visibility toggle', async () => {
+ createView();
+
+ await user.type(screen.getByLabelText('Username'), 'testuser');
+ await user.type(screen.getByLabelText('Password'), 'secure_password');
+
+ const passwordField = screen.getByLabelText('Password');
+ expect(passwordField).toHaveAttribute('type', 'password');
+
+ const visibilityIcon = screen.getByLabelText('Show password');
+ await user.click(visibilityIcon);
+
+ expect(passwordField).toHaveAttribute('type', 'text');
+
+ const hideVisibilityIcon = screen.getByLabelText('Hide password');
+ await user.click(hideVisibilityIcon);
+
+ expect(passwordField).toHaveAttribute('type', 'password');
+ });
+ });
+});
diff --git a/src/admin/users/userDialogue.component.tsx b/src/admin/users/userDialogue.component.tsx
new file mode 100644
index 00000000..05f07cbd
--- /dev/null
+++ b/src/admin/users/userDialogue.component.tsx
@@ -0,0 +1,259 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
+import {
+ Autocomplete,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormHelperText,
+ IconButton,
+ TextField,
+} from '@mui/material';
+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 { AUTH_TYPE_LIST, AUTHORISED_ROUTE_LIST } from './usersTable.component';
+
+export interface UserDialogueProps {
+ onClose: () => void;
+ open: boolean;
+ requestType: 'post' | 'patch';
+}
+
+interface BaseZodSchemaProps {
+ errorMessage?: string;
+}
+
+const OptionalStringSchema = z
+ .string()
+ .trim()
+ .transform((val) => (!val ? undefined : val))
+ .optional();
+
+const MandatoryStringSchema = (props: BaseZodSchemaProps) =>
+ z
+ .string({
+ required_error: props.errorMessage,
+ })
+ .trim()
+ .min(1, { message: props.errorMessage });
+
+// Define Zod schema for the User form
+const userSchema = z.object({
+ _id: MandatoryStringSchema({ errorMessage: 'Username is required.' }),
+ sha256_password: OptionalStringSchema,
+ auth_type: MandatoryStringSchema({}),
+ authorised_routes: z
+ .array(z.string())
+ .transform((val) => (val.length === 0 || !val ? undefined : val))
+ .optional(),
+});
+
+const UserDialogue = (props: UserDialogueProps) => {
+ const { open, onClose, requestType } = props;
+
+ const initialUser: UserPost = {
+ _id: '',
+ sha256_password: '',
+ auth_type: 'local',
+ authorised_routes: [],
+ };
+
+ const {
+ control,
+ register,
+ watch,
+ handleSubmit,
+ clearErrors,
+ setError,
+ reset,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(userSchema),
+ defaultValues: initialUser,
+ });
+ const userFormData = watch();
+ React.useEffect(() => {
+ if (userFormData.auth_type === 'FedID') {
+ setValue('sha256_password', undefined);
+ }
+ }, [setValue, userFormData.auth_type]);
+
+ const handleClose = React.useCallback(() => {
+ reset();
+ clearErrors();
+ onClose();
+ }, [clearErrors, onClose, reset]);
+
+ const { mutateAsync: addUser, isPending: isAddPending } = useAddUser();
+ const handleAddUser = React.useCallback(
+ async (user: User) => {
+ addUser(user)
+ .then(() => handleClose())
+ .catch((error: AxiosError) => {
+ const errorDetail = (error.response?.data as APIError).detail;
+
+ if (typeof errorDetail === 'string') {
+ let field: 'root.formError' | 'sha256_password' | '_id' =
+ 'root.formError';
+ let message: string = errorDetail;
+
+ if (errorDetail.toLowerCase().includes('password')) {
+ field = 'sha256_password';
+ } else if (errorDetail.toLowerCase().includes('username')) {
+ field = '_id';
+ } else {
+ message = 'An unexpected error occurred. Please try again later.';
+ }
+
+ setError(field, { message });
+ }
+ });
+ },
+ [addUser, handleClose, setError]
+ );
+
+ const onSubmit = (data: UserPost) => {
+ const newData: UserPost = {
+ ...data,
+ ...(data.sha256_password && {
+ sha256_password: data.sha256_password,
+ }),
+ };
+
+ if (requestType === 'post') handleAddUser(newData);
+ };
+
+ const [showPassword, setShowPassword] = React.useState(false);
+
+ const togglePasswordVisibility = () => {
+ setShowPassword(!showPassword);
+ };
+
+ return (
+
+ );
+};
+
+export default UserDialogue;
diff --git a/src/admin/users/usersTable.component.test.tsx b/src/admin/users/usersTable.component.test.tsx
index cf523aa8..e4f976d4 100644
--- a/src/admin/users/usersTable.component.test.tsx
+++ b/src/admin/users/usersTable.component.test.tsx
@@ -1,9 +1,71 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent, { UserEvent } from '@testing-library/user-event';
import { renderComponentWithProviders } from '../../testUtils';
import UsersTable from './usersTable.component'; // Update with the correct path
describe('UsersTable Snapshot', () => {
+ let user: UserEvent;
+ const createView = () => {
+ return renderComponentWithProviders();
+ };
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
it('matches snapshot', () => {
- const { asFragment } = renderComponentWithProviders();
+ const { asFragment } = createView();
expect(asFragment()).toMatchSnapshot();
});
+
+ it('opens add dialog and closes it correctly', async () => {
+ createView();
+ await waitFor(() => {
+ expect(screen.getByText('user1')).toBeInTheDocument();
+ });
+
+ const addButton = screen.getByRole('button', { name: 'Add User' });
+ await user.click(addButton);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('dialog', { name: 'Add User' })
+ ).toBeInTheDocument();
+ });
+
+ const closeButton = screen.getByRole('button', { name: 'Cancel' });
+ await user.click(closeButton);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('dialog', { name: 'Add User' })
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('sets the table filters and clears the table filters', async () => {
+ createView();
+
+ await waitFor(() => {
+ expect(screen.getByText('user1')).toBeInTheDocument();
+ });
+ const clearFiltersButton = screen.getByRole('button', {
+ name: 'Clear Filters',
+ });
+
+ expect(clearFiltersButton).toBeDisabled();
+
+ const nameInput = screen.getByLabelText('Filter by Username');
+
+ await user.type(nameInput, '9');
+
+ await waitFor(() => {
+ expect(screen.queryByText('user1')).not.toBeInTheDocument();
+ });
+
+ await user.click(clearFiltersButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('user1')).toBeInTheDocument();
+ });
+ }, 10000);
});
diff --git a/src/admin/users/usersTable.component.tsx b/src/admin/users/usersTable.component.tsx
index 1092fb1a..125e3108 100644
--- a/src/admin/users/usersTable.component.tsx
+++ b/src/admin/users/usersTable.component.tsx
@@ -1,14 +1,18 @@
-import { Chip, Stack } from '@mui/material';
+import AddIcon from '@mui/icons-material/Add';
+import ClearIcon from '@mui/icons-material/Clear';
+import { Box, Button, Chip, Stack } from '@mui/material';
import {
MaterialReactTable,
MRT_ColumnDef,
useMaterialReactTable,
} from 'material-react-table';
import { MRT_Localization_EN } from 'material-react-table/locales/en';
+import React from 'react';
import { useUsers } from '../../api/user';
import { User } from '../../app.types';
+import UserDialogue from './userDialogue.component';
-const AUTHORISED_ROUTE_LIST = [
+export const AUTHORISED_ROUTE_LIST = [
'/submit/hdf POST',
'/submit/manifest POST',
'/records/{id_} DELETE',
@@ -18,15 +22,19 @@ const AUTHORISED_ROUTE_LIST = [
'/users/{id_} DELETE',
];
-const AUTH_TYPE_LIST = ['local', 'FedID'];
+export const AUTH_TYPE_LIST = ['local', 'FedID'];
function UsersTable() {
const { data: userData, isLoading: userDataLoading } = useUsers();
+ const [requestType, setRequestType] = React.useState<'patch' | 'post'>(
+ 'post'
+ );
+
// Define the columns for the table
const columns: MRT_ColumnDef[] = [
{
accessorKey: '_id',
- header: 'User ID',
+ header: 'Username',
},
{
@@ -45,7 +53,7 @@ function UsersTable() {
return routes && routes.length > 0 ? (
{routes.map((route, index) => (
-
+
))}
) : (
@@ -93,6 +101,45 @@ function UsersTable() {
shape: 'rounded',
variant: 'outlined',
},
+ renderCreateRowDialogContent: ({ table }) => {
+ return (
+ <>
+ {
+ table.setCreatingRow(null);
+ }}
+ />
+ >
+ );
+ },
+ renderTopToolbarCustomActions: ({ table }) => (
+
+ }
+ sx={{ mx: '4px' }}
+ variant="outlined"
+ onClick={() => {
+ setRequestType('post');
+ table.setCreatingRow(true);
+ }}
+ >
+ Add User
+
+ }
+ sx={{ mx: '4px' }}
+ variant="outlined"
+ disabled={table.getState().columnFilters.length === 0}
+ onClick={() => {
+ table.resetColumnFilters();
+ }}
+ >
+ Clear Filters
+
+
+ ),
});
return ;
}
diff --git a/src/api/user.test.tsx b/src/api/user.test.tsx
index 0763e0b8..006814cf 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 { useUsers } from './user';
+import { useAddUser, useUsers } from './user';
describe('useUsers', () => {
it('sends request to fetch users and returns successful response', async () => {
@@ -21,3 +21,24 @@ describe('useUsers', () => {
'sends axios request to fetch users and throws an appropriate error on failure'
);
});
+
+describe('useAddUser', () => {
+ it('posts a request to add a user and returns successful response', async () => {
+ const { result } = renderHook(() => useAddUser(), {
+ wrapper: hooksWrapperWithProviders(),
+ });
+ expect(result.current.isIdle).toBe(true);
+
+ result.current.mutate({ ...usersJson[0], sha256_password: 'test' });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+
+ expect(result.current.data).toEqual(usersJson[0]._id);
+ });
+
+ it.todo(
+ 'sends axios request to post user session and throws an appropriate error on failure'
+ );
+});
diff --git a/src/api/user.tsx b/src/api/user.tsx
index feab09a0..ebf4882c 100644
--- a/src/api/user.tsx
+++ b/src/api/user.tsx
@@ -1,6 +1,12 @@
-import { UseQueryResult, useQuery } from '@tanstack/react-query';
+import {
+ UseMutationResult,
+ UseQueryResult,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
-import { User } from '../app.types';
+import { User, type UserPost } from '../app.types';
import { readSciGatewayToken } from '../parseTokens';
import { useAppSelector } from '../state/hooks';
import { selectUrls } from '../state/slices/configSlice';
@@ -27,3 +33,31 @@ export const useUsers = (): UseQueryResult => {
},
});
};
+
+const addUser = (apiUrl: string, user: UserPost): Promise => {
+ return axios
+ .post(`${apiUrl}/users`, user, {
+ headers: {
+ Authorization: `Bearer ${readSciGatewayToken()}`,
+ },
+ })
+ .then((response) => response.data);
+};
+
+export const useAddUser = (): UseMutationResult<
+ string,
+ AxiosError,
+ UserPost
+> => {
+ const { apiUrl } = useAppSelector(selectUrls);
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (user: UserPost) => addUser(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 165cda20..f4c47dd7 100644
--- a/src/app.types.tsx
+++ b/src/app.types.tsx
@@ -318,6 +318,13 @@ export interface User {
authorised_routes?: string[] | null;
}
+export interface UserPost {
+ _id: string; // Maps the `username` field in Python, which has an alias "_id"
+ auth_type: string;
+ sha256_password?: string;
+ authorised_routes?: string[] | null;
+}
+
export interface FavouriteFilterPost {
name: string;
filter: string;
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
index 0afb4f3d..5c9b6071 100644
--- a/src/mocks/handlers.ts
+++ b/src/mocks/handlers.ts
@@ -7,6 +7,7 @@ import {
isChannelScalar,
Record,
ValidateFunctionPost,
+ type UserPost,
} from '../app.types';
import { PREFERRED_COLOUR_MAP_PREFERENCE_NAME } from '../settingsMenuItems.component';
import channelsJson from './channels.json';
@@ -378,6 +379,39 @@ export const handlers = [
return HttpResponse.json(usersJson, { status: 200 });
}),
+ http.post('/users', async ({ request }) => {
+ const body = (await request.json()) as UserPost;
+
+ if (body.auth_type === 'local' && !body.sha256_password) {
+ return HttpResponse.json(
+ {
+ detail:
+ 'for the auth_type you put (local), a password is required. Please add this field',
+ },
+ { status: 400 }
+ );
+ }
+
+ if (body._id === 'test_dup') {
+ return HttpResponse.json(
+ {
+ detail: `username field must not be the same as a pre existing user. You put: ${body._id} `,
+ },
+ { status: 400 }
+ );
+ }
+
+ if (body._id === 'error') {
+ return HttpResponse.json(
+ {
+ detail: 'Unknown error',
+ },
+ { status: 400 }
+ );
+ }
+ return HttpResponse.json(body._id, { status: 201 });
+ }),
+
http.post('/users/filters', async () => {
return HttpResponse.json('1', { status: 201 });
}),
diff --git a/yarn.lock b/yarn.lock
index 84b66b3e..d3dc6bf3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -814,6 +814,15 @@ __metadata:
languageName: node
linkType: hard
+"@hookform/resolvers@npm:^3.9.1":
+ version: 3.9.1
+ resolution: "@hookform/resolvers@npm:3.9.1"
+ peerDependencies:
+ react-hook-form: ^7.0.0
+ checksum: 10c0/8a4056db3860b12ee30921ba352996104d6ae75ac45996d4c8b6df429e07ee73f5b87c82a22a15403789213f6f52f5fead1c2637b26ef624068b68d213362cd1
+ languageName: node
+ linkType: hard
+
"@humanfs/core@npm:^0.19.1":
version: 0.19.1
resolution: "@humanfs/core@npm:0.19.1"
@@ -1187,8 +1196,8 @@ __metadata:
linkType: hard
"@mui/utils@npm:^5.14.16, @mui/utils@npm:^5.15.14, @mui/utils@npm:^5.16.2, @mui/utils@npm:^5.16.6":
- version: 5.16.8
- resolution: "@mui/utils@npm:5.16.8"
+ version: 5.16.6
+ resolution: "@mui/utils@npm:5.16.6"
dependencies:
"@babel/runtime": "npm:^7.23.9"
"@mui/types": "npm:^7.2.15"
@@ -1197,12 +1206,12 @@ __metadata:
prop-types: "npm:^15.8.1"
react-is: "npm:^18.3.1"
peerDependencies:
- "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
- react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ "@types/react": ^17.0.0 || ^18.0.0
+ react: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
"@types/react":
optional: true
- checksum: 10c0/86a1daf249a1dc766c0babe439c6874e092ce8239bdd67a57e81fc349934bdee0c98ee1833b282286847ffc16966edff88d9e2a47ac98577fb1010245191888a
+ checksum: 10c0/2db3d11a83d7216fb8ceb459d4b30c795922c04cd8fabc26c721dd7b4f5ed5c4f3f3ace6ea70227bf3b79361bd58f13b723562cfd40255424d979ab238ab2e91
languageName: node
linkType: hard
@@ -6908,6 +6917,7 @@ __metadata:
"@emotion/styled": "npm:11.14.0"
"@eslint/compat": "npm:1.2.4"
"@hello-pangea/dnd": "npm:17.0.0"
+ "@hookform/resolvers": "npm:^3.9.1"
"@mui/base": "npm:5.0.0-beta.40"
"@mui/icons-material": "npm:5.16.2"
"@mui/material": "npm:5.16.2"
@@ -6963,6 +6973,7 @@ __metadata:
react: "npm:18.3.1"
react-colorful: "npm:5.6.1"
react-dom: "npm:18.3.1"
+ react-hook-form: "npm:^7.53.2"
react-redux: "npm:9.2.0"
react-router-dom: "npm:7.1.1"
serve: "npm:14.2.0"
@@ -6975,6 +6986,7 @@ __metadata:
vitest: "npm:2.1.8"
vitest-canvas-mock: "npm:0.3.3"
vitest-fail-on-console: "npm:0.7.1"
+ zod: "npm:^3.23.8"
languageName: unknown
linkType: soft
@@ -7515,6 +7527,15 @@ __metadata:
languageName: node
linkType: hard
+"react-hook-form@npm:^7.53.2":
+ version: 7.53.2
+ resolution: "react-hook-form@npm:7.53.2"
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+ checksum: 10c0/18336d8e8798a70dcd0af703a0becca2d5dbf82a7b7a3ca334ae0e1f26410490bc3ef2ea51adcf790bb1e7006ed7a763fd00d664e398f71225b23529a7ccf0bf
+ languageName: node
+ linkType: hard
+
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
@@ -9692,3 +9713,10 @@ __metadata:
checksum: 10c0/a0e36eb88fea2c7981eab22d1ba45e15d8d268626e6c4143305e2c1628fa17ebfaa40cd306161a8ce04c0a60ee0262058eab12567493d5eb1409780853454c6f
languageName: node
linkType: hard
+
+"zod@npm:^3.23.8":
+ version: 3.23.8
+ resolution: "zod@npm:3.23.8"
+ checksum: 10c0/8f14c87d6b1b53c944c25ce7a28616896319d95bc46a9660fe441adc0ed0a81253b02b5abdaeffedbeb23bdd25a0bf1c29d2c12dd919aef6447652dd295e3e69
+ languageName: node
+ linkType: hard