diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 539e73b96..b0b529738 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,11 @@ +## April 7, 2026 + +- **Feature** Add "delete user" functionality to user management section [🎟️ DEP-239](https://citz-gdx.atlassian.net/browse/DEP-239) + - Added a "Delete User" button to the user details page in the admin view, visible only to super admins. + - Updated the API to include a DELETE endpoint for users, which checks for appropriate permissions and handles cascading deletions of related data (e.g. role assignments). + - Added success and error messages to provide feedback on the deletion process. + - Reworked the user details component to rearrange the existing layout and accommodate the new delete button, while ensuring a clear and intuitive user experience. + ## April 1, 2026 - **Chore** Change trigger for environment detection [🎟️ DEP-242](https://citz-gdx.atlassian.net/browse/DEP-242) diff --git a/api/sample.env b/api/sample.env index cd22815d9..d3d0d7b0d 100644 --- a/api/sample.env +++ b/api/sample.env @@ -91,7 +91,7 @@ ACCESS_REQUEST_EMAIL_ADDRESS="accessRequestHandler.fakeName@gov.bc.ca" # Site paths for creating emails from templates SITE_URL=http://localhost:3000 SURVEY_PATH=/surveys/submit/{survey_id}/{token}/{lang} -USER_MANAGEMENT_PATH=/usermanagement +USER_MANAGEMENT_PATH=/usermanagement/{id}/details SUBMISSION_PATH=/engagements/{engagement_id}/edit/{token}/{lang} SUBSCRIBE_PATH=/engagements/{engagement_id}/subscribe/{token}/{lang} UNSUBSCRIBE_PATH=/engagements/{engagement_id}/unsubscribe/{participant_id}/{lang} diff --git a/api/src/api/config.py b/api/src/api/config.py index 2c32d31f3..a52d6dcba 100644 --- a/api/src/api/config.py +++ b/api/src/api/config.py @@ -240,7 +240,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> str: PATH_CONFIG = PATHS = { 'SITE': os.getenv('SITE_URL'), 'SURVEY': os.getenv('SURVEY_PATH', '/surveys/submit/{survey_id}/{token}/{lang}'), - 'USER_MANAGEMENT': os.getenv('USER_MANAGEMENT_PATH', '/usermanagement'), + 'USER_MANAGEMENT': os.getenv('USER_MANAGEMENT_PATH', '/usermanagement/{id}/details'), 'SUBMISSION': os.getenv( 'SUBMISSION_PATH', '/engagements/{engagement_id}/edit/{token}/{lang}' ), diff --git a/api/src/api/resources/staff_user.py b/api/src/api/resources/staff_user.py index fb90b23a0..4070846e9 100644 --- a/api/src/api/resources/staff_user.py +++ b/api/src/api/resources/staff_user.py @@ -86,7 +86,7 @@ def get(): return jsonify(users), HTTPStatus.OK -@cors_preflight('GET') +@cors_preflight('GET, DELETE') @API.route('/') class StaffUser(Resource): """User controller class.""" @@ -104,6 +104,18 @@ def get(user_id): ) return user, HTTPStatus.OK + @staticmethod + @cross_origin(origins=allowedorigins()) + @require_role([Role.SUPER_ADMIN.value]) + def delete(user_id): + """Permanently delete an inactive user.""" + try: + user_data = TokenInfo.get_user_data() + StaffUserService.delete_deactivated_user(user_id, user_data.get('external_id')) + return {'message': 'User deleted successfully.'}, HTTPStatus.OK + except BusinessException as err: + return {'message': err.error}, err.status_code + @cors_preflight('PATCH') @API.route('//status') diff --git a/api/src/api/services/staff_user_service.py b/api/src/api/services/staff_user_service.py index d106be8c8..22c4968b7 100644 --- a/api/src/api/services/staff_user_service.py +++ b/api/src/api/services/staff_user_service.py @@ -1,16 +1,20 @@ """Service for user management.""" from http import HTTPStatus +from typing import Optional from flask import current_app, g from api.exceptions.business_exception import BusinessException +from api.models.db import db +from api.models.membership import Membership from api.models.pagination_options import PaginationOptions from api.models.staff_user import StaffUser as StaffUserModel +from api.models.user_group_membership import UserGroupMembership from api.schemas.staff_user import StaffUserSchema from api.services.user_group_membership_service import UserGroupMembershipService from api.utils import notification from api.utils.constants import CompositeRoles -from api.utils.enums import CompositeRoleId, CompositeRoleNames +from api.utils.enums import CompositeRoleId, CompositeRoleNames, UserStatus from api.utils.template import Template @@ -78,7 +82,7 @@ def _render_email_template(user: StaffUserModel): subject = templates['ACCESS_REQUEST']['SUBJECT'] grant_access_url = notification.get_tenant_site_url( user.tenant_id, paths['USER_MANAGEMENT'] - ) + ).replace('{id}', str(user.id)) email_environment = templates['ENVIRONMENT'] args = { 'first_name': user.first_name, @@ -170,3 +174,29 @@ def validate_user(db_user: StaffUserModel): raise BusinessException( error='This user is already an Administrator.', status_code=HTTPStatus.CONFLICT.value) + + @classmethod + def delete_deactivated_user(cls, user_id, actor_external_id: Optional[str] = None): + """Permanently delete a user after required safeguards are satisfied.""" + db_user = StaffUserModel.get_by_id(user_id, include_inactive=True) + if db_user is None: + raise BusinessException(error='User not found.', status_code=HTTPStatus.NOT_FOUND) + + if db_user.status_id != UserStatus.INACTIVE.value: + raise BusinessException( + error='User must be deactivated before deletion.', + status_code=HTTPStatus.BAD_REQUEST, + ) + + if actor_external_id and db_user.external_id.lower() == actor_external_id.lower(): + raise BusinessException( + error='You cannot delete your own account.', + status_code=HTTPStatus.CONFLICT, + ) + + Membership.query.filter(Membership.user_id == db_user.id).delete(synchronize_session=False) + UserGroupMembership.query.filter( + UserGroupMembership.staff_user_external_id == db_user.external_id, + ).delete(synchronize_session=False) + db.session.delete(db_user) + db.session.commit() diff --git a/api/tests/unit/models/test_user.py b/api/tests/unit/models/test_user.py index a94924fef..6b4c76841 100644 --- a/api/tests/unit/models/test_user.py +++ b/api/tests/unit/models/test_user.py @@ -88,4 +88,3 @@ def test_update_user_from_dict_valid(session): new_email = fake.email() updated_user = StaffUserModel.update_user(new_user.id, {'email_address': new_email}) assert updated_user.email_address == new_email - diff --git a/web/src/App.tsx b/web/src/App.tsx index a332b77c0..2f5287dcb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -227,10 +227,10 @@ const App = () => { lazy: () => { return Promise.all([ import('routes/AuthenticatedRootRouteLoader'), - import('components/appLayouts/SimplifiedLayout'), + import('components/appLayouts/PublicLayout'), ]).then(([loaderModule, layoutModule]) => ({ - Component: layoutModule.default, loader: loaderModule.authenticatedRootLoader, + Component: layoutModule.default, })); }, children: [ diff --git a/web/src/apiManager/endpoints/index.ts b/web/src/apiManager/endpoints/index.ts index 57634ee21..b667e6a8a 100644 --- a/web/src/apiManager/endpoints/index.ts +++ b/web/src/apiManager/endpoints/index.ts @@ -46,6 +46,7 @@ const Endpoints = { }, User: { GET: `${AppConfig.apiUrl}/user/user_id`, + DELETE: `${AppConfig.apiUrl}/user/user_id`, CREATE_UPDATE: `${AppConfig.apiUrl}/user/`, GET_LIST: `${AppConfig.apiUrl}/user/`, ADD_TO_COMPOSITE_ROLE: `${AppConfig.apiUrl}/user/user_id/roles`, diff --git a/web/src/components/userManagement/common/EngagementAutocomplete.tsx b/web/src/components/userManagement/common/EngagementAutocomplete.tsx new file mode 100644 index 000000000..32eccfe3a --- /dev/null +++ b/web/src/components/userManagement/common/EngagementAutocomplete.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Autocomplete, CircularProgress, TextField } from '@mui/material'; +import { Engagement } from 'models/engagement'; +import { useEngagementSearch } from './useEngagementSearch'; + +export interface EngagementAutocompleteProps { + value: Engagement | null | undefined; + onChange: (engagement: Engagement | null) => void; + error?: boolean; + helperText?: string; + hasTeamAccess?: boolean; +} + +export const EngagementAutocomplete = ({ + value, + onChange, + error, + helperText, + hasTeamAccess, +}: EngagementAutocompleteProps) => { + const { options, loading, onInputChange } = useEngagementSearch({ hasTeamAccess }); + + return ( + onChange(data)} + onInputChange={(_, newValue) => onInputChange(newValue)} + getOptionLabel={(engagement: Engagement) => engagement.name} + isOptionEqualToValue={(option, val) => option.id === val.id} + loading={loading} + slotProps={{ + paper: { sx: { maxHeight: 48 * 4.5 + 8, overflowY: 'auto' } }, + }} + renderInput={(params) => ( + + {loading && } + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +}; diff --git a/web/src/components/userManagement/common/ModalActions.tsx b/web/src/components/userManagement/common/ModalActions.tsx new file mode 100644 index 000000000..c9254b382 --- /dev/null +++ b/web/src/components/userManagement/common/ModalActions.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Grid2 as Grid, Stack } from '@mui/material'; +import { Button } from 'components/common/Input/Button'; + +interface ModalActionsProps { + onClose: () => void; + loading?: boolean; + submitLabel?: string; + cancelLabel?: string; +} + +export const ModalActions = ({ + onClose, + loading, + submitLabel = 'Submit', + cancelLabel = 'Cancel', +}: ModalActionsProps) => ( + + + + + + +); diff --git a/web/src/components/userManagement/common/RoleAssignmentModal.tsx b/web/src/components/userManagement/common/RoleAssignmentModal.tsx new file mode 100644 index 000000000..63dc2a4a9 --- /dev/null +++ b/web/src/components/userManagement/common/RoleAssignmentModal.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Grid2 as Grid, MenuItem, Modal, Paper, Select } from '@mui/material'; +import { Button } from 'components/common/Input/Button'; +import { BodyText, Heading3 } from 'components/common/Typography'; +import { modalStyle } from 'components/common'; +import { useAppDispatch, useAppSelector } from 'hooks'; +import { USER_COMPOSITE_ROLE, User } from 'models/user'; +import { addUserToRole, changeUserRole } from 'services/userService/api'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { USER_ROLES } from 'services/userService/constants'; + +const roleOptions = [ + USER_COMPOSITE_ROLE.ADMIN, + USER_COMPOSITE_ROLE.VIEWER, + USER_COMPOSITE_ROLE.TEAM_MEMBER, + USER_COMPOSITE_ROLE.REVIEWER, +]; + +interface RoleAssignmentModalProps { + open: boolean; + user?: User; + onClose: () => void; + onSaved?: () => Promise | void; +} + +export const RoleAssignmentModal = ({ open, user, onClose, onSaved }: RoleAssignmentModalProps) => { + const dispatch = useAppDispatch(); + const { roles } = useAppSelector((state) => state.user); + const [selectedRole, setSelectedRole] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + const isSuperAdmin = roles.includes(USER_ROLES.SUPER_ADMIN); + const isAdminUser = user?.main_role === USER_COMPOSITE_ROLE.ADMIN.label; + const isReassignment = Boolean(user?.main_role); + + const initialRoleValue = useMemo( + () => roleOptions.find((role) => role.label === user?.main_role)?.value || '', + [user?.main_role], + ); + + useEffect(() => { + if (!open) { + setSelectedRole(''); + return; + } + + setSelectedRole(initialRoleValue); + }, [initialRoleValue, open]); + + const handleClose = () => { + if (isSaving) { + return; + } + + setSelectedRole(''); + onClose(); + }; + + const handleSave = async () => { + if (!user || !selectedRole) { + return; + } + + if (isAdminUser && !isSuperAdmin) { + dispatch( + openNotification({ + severity: 'error', + text: 'Only Super Admins can reassign an Administrator to a lower role.', + }), + ); + return; + } + + try { + setIsSaving(true); + + if (user.main_role) { + await changeUserRole({ + user_id: user.id, + role: selectedRole, + }); + } else { + await addUserToRole({ + user_id: user.external_id, + role: selectedRole, + }); + } + + await onSaved?.(); + handleClose(); + + const selectedRoleLabel = roleOptions.find((role) => role.value === selectedRole)?.label || selectedRole; + dispatch( + openNotification({ + severity: 'success', + text: isReassignment + ? `You have reassigned ${user.first_name} ${user.last_name} as ${selectedRoleLabel}.` + : `You have successfully added ${user.first_name} ${user.last_name} to the role ${selectedRoleLabel}.`, + }), + ); + } catch { + dispatch(openNotification({ severity: 'error', text: 'Failed to update user role.' })); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + {isReassignment ? 'Reassign Role' : 'Assign Role'} + + + + Choose a role for {user?.first_name} {user?.last_name}. + + + + + + + + + + + + + + + + + ); +}; diff --git a/web/src/components/userManagement/common/useEngagementSearch.ts b/web/src/components/userManagement/common/useEngagementSearch.ts new file mode 100644 index 000000000..57aff756b --- /dev/null +++ b/web/src/components/userManagement/common/useEngagementSearch.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef, useState } from 'react'; +import { debounce } from 'lodash'; +import { Engagement } from 'models/engagement'; +import { getEngagements } from 'services/engagementService'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { useAppDispatch } from 'hooks'; + +interface UseEngagementSearchOptions { + hasTeamAccess?: boolean; + debounceMs?: number; +} + +export const useEngagementSearch = ({ hasTeamAccess, debounceMs = 400 }: UseEngagementSearchOptions = {}) => { + const dispatch = useAppDispatch(); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const search = async (searchText: string) => { + setLoading(true); + try { + const response = await getEngagements({ + search_text: searchText, + sort_key: searchText ? 'name' : 'engagement_created_date', + sort_order: searchText ? 'asc' : 'desc', + ...(hasTeamAccess !== undefined && { has_team_access: hasTeamAccess }), + }); + setOptions(response.items); + } catch { + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while trying to fetch engagements, please refresh the page or try again at a later time', + }), + ); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + search(''); + }, [hasTeamAccess]); + + const onInputChange = useRef(debounce(search, debounceMs)).current; + + return { options, loading, onInputChange }; +}; diff --git a/web/src/components/userManagement/listing/ActionsDropDown.tsx b/web/src/components/userManagement/listing/ActionsDropDown.tsx index 8b60acbe3..4438bbc2e 100644 --- a/web/src/components/userManagement/listing/ActionsDropDown.tsx +++ b/web/src/components/userManagement/listing/ActionsDropDown.tsx @@ -76,6 +76,8 @@ export const ActionsDropDown = ({ selectedUser }: { selectedUser: User }) => { condition: !hasNoRole() && roles.includes(USER_ROLES.UPDATE_USER_GROUP) && + (selectedUser.main_role !== USER_COMPOSITE_ROLE.ADMIN.label || + roles.includes(USER_ROLES.SUPER_ADMIN)) && selectedUser.status_id == USER_STATUS.ACTIVE.value && selectedUser.id != userDetail?.user?.id, }, diff --git a/web/src/components/userManagement/listing/AddUserModal.tsx b/web/src/components/userManagement/listing/AddUserModal.tsx index 56d1b7f79..a02148108 100644 --- a/web/src/components/userManagement/listing/AddUserModal.tsx +++ b/web/src/components/userManagement/listing/AddUserModal.tsx @@ -1,21 +1,19 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import Modal from '@mui/material/Modal'; -import { Autocomplete, CircularProgress, Grid2 as Grid, Paper, Stack, TextField, useTheme } from '@mui/material'; +import { Grid2 as Grid, Paper } from '@mui/material'; import { modalStyle } from 'components/common'; import { Heading3, BodyText } from 'components/common/Typography'; -import { Button } from 'components/common/Input/Button'; import { UserManagementContext } from './UserManagementContext'; import { useForm, FormProvider, SubmitHandler, Controller, Resolver } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; -import { getEngagements } from 'services/engagementService'; import { addTeamMemberToEngagement } from 'services/membershipService'; import { When } from 'react-if'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch } from 'hooks'; -import { debounce } from 'lodash'; -import { Engagement } from 'models/engagement'; import axios, { AxiosError } from 'axios'; +import { EngagementAutocomplete } from 'components/userManagement/common/EngagementAutocomplete'; +import { ModalActions } from 'components/userManagement/common/ModalActions'; const schema = yup .object({ @@ -35,12 +33,8 @@ export const AddUserModal = () => { const dispatch = useAppDispatch(); const { addUserModalOpen, setAddUserModalOpen, user, loadUserListing } = useContext(UserManagementContext); const [isAddingToEngagement, setIsAddingToEngagement] = useState(false); - const [engagements, setEngagements] = useState([]); - const [engagementsLoading, setEngagementsLoading] = useState(false); const [backendError, setBackendError] = useState(''); - const theme = useTheme(); - const methods = useForm({ resolver: yupResolver(schema) as unknown as Resolver, }); @@ -68,37 +62,6 @@ export const AddUserModal = () => { setBackendError(''); }; - const loadEngagements = async (searchText: string) => { - if (searchText.length < 3) { - return; - } - try { - setEngagementsLoading(true); - const response = await getEngagements({ - search_text: searchText, - has_team_access: true, - }); - setEngagements(response.items); - setEngagementsLoading(false); - } catch { - dispatch( - openNotification({ - severity: 'error', - text: 'Error occurred while trying to fetch engagements, please refresh the page or try again at a later time', - }), - ); - setEngagementsLoading(false); - } - }; - - const debounceLoadEngagements = useRef( - debounce((searchText: string) => { - loadEngagements(searchText).catch((error) => { - console.error('Error in debounceLoadEngagements:', error); - }); - }, 1000), - ).current; - const addUserToEngagement = async (data: AddUserForm) => { await addTeamMemberToEngagement({ user_id: user?.external_id, @@ -163,43 +126,13 @@ export const AddUserModal = () => { ( - { - onChange(data); - }} - onInputChange={(_event, newInputValue) => { - debounceLoadEngagements(newInputValue); - }} - renderInput={(params) => ( - - {engagementsLoading && ( - - )} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - getOptionLabel={(engagement: Engagement) => engagement.name} - loading={engagementsLoading} + render={({ field: { onChange, value } }) => ( + )} /> @@ -207,32 +140,13 @@ export const AddUserModal = () => { - + {backendError} - - - - - - + diff --git a/web/src/components/userManagement/listing/AssignRoleModal.tsx b/web/src/components/userManagement/listing/AssignRoleModal.tsx index 5a6c7ce27..435482350 100644 --- a/web/src/components/userManagement/listing/AssignRoleModal.tsx +++ b/web/src/components/userManagement/listing/AssignRoleModal.tsx @@ -1,321 +1,15 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; -import Modal from '@mui/material/Modal'; -import { - Autocomplete, - CircularProgress, - FormControl, - FormControlLabel, - FormHelperText, - FormLabel, - Grid2 as Grid, - Paper, - Radio, - Stack, - TextField, - useTheme, -} from '@mui/material'; -import { modalStyle } from 'components/common'; -import { USER_COMPOSITE_ROLE } from 'models/user'; +import React, { useContext } from 'react'; import { UserManagementContext } from './UserManagementContext'; -import { Palette } from 'styles/Theme'; -import { useForm, FormProvider, SubmitHandler, Controller, Resolver } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as yup from 'yup'; -import ControlledRadioGroup from 'components/common/ControlledInputComponents/ControlledRadioGroup'; -import { addUserToRole } from 'services/userService/api'; -import { addTeamMemberToEngagement } from 'services/membershipService'; -import { When } from 'react-if'; -import { openNotification } from 'services/notificationService/notificationSlice'; -import { useAppDispatch } from 'hooks'; -import axios, { AxiosError } from 'axios'; -import { getEngagements } from 'services/engagementService'; -import { debounce } from 'lodash'; -import { Engagement } from 'models/engagement'; -import { BodyText, Heading3 } from 'components/common/Typography'; -import { Button } from 'components/common/Input/Button'; - -const schema = yup - .object({ - role: yup.string().required('A role must be specified'), - engagement: yup - .mixed() - .nullable() - .when('role', { - is: USER_COMPOSITE_ROLE.REVIEWER.value, - then: (schema) => schema.required('An engagement must be selected'), - }), - }) - .required(); - -type AssignRoleForm = yup.TypeOf; +import { RoleAssignmentModal } from 'components/userManagement/common/RoleAssignmentModal'; export const AssignRoleModal = () => { - const dispatch = useAppDispatch(); const { assignRoleModalOpen, setassignRoleModalOpen, user, loadUserListing } = useContext(UserManagementContext); - const [isAssigningRole, setIsAssigningRole] = useState(false); - - const [engagements, setEngagements] = useState([]); - const [engagementsLoading, setEngagementsLoading] = useState(false); - const [backendError, setBackendError] = useState(''); - const theme = useTheme(); - - const methods = useForm({ - resolver: yupResolver(schema) as unknown as Resolver, - }); - - const { - handleSubmit, - control, - reset, - formState: { errors }, - watch, - } = methods; - - const userTypeSelected = watch('role'); - - const formValues = watch(); - useEffect(() => { - if (backendError) { - setBackendError(''); - } - }, [JSON.stringify(formValues)]); - - const { role: roleErrors, engagement: engagementErrors } = errors; - - const handleClose = () => { - setassignRoleModalOpen(false); - reset({}); - setBackendError(''); - }; - - const setErrors = (error: AxiosError<{ message?: string }>) => { - if (error.response?.status !== 409) { - return; - } - setBackendError(error.response?.data?.message || ''); - }; - - const loadEngagements = async (searchText: string) => { - if (searchText.length < 3) { - return; - } - try { - setEngagementsLoading(true); - const response = await getEngagements({ - search_text: searchText, - }); - setEngagements(response.items); - setEngagementsLoading(false); - } catch { - dispatch( - openNotification({ - severity: 'error', - text: 'Error occurred while trying to fetch engagements, please refresh the page or try again at a later time', - }), - ); - setEngagementsLoading(false); - } - }; - - const debounceLoadEngagements = useRef( - debounce((searchText: string) => { - loadEngagements(searchText); - }, 1000), - ).current; - - const assignRoleToUser = async (data: AssignRoleForm) => { - if (userTypeSelected === USER_COMPOSITE_ROLE.ADMIN.value) { - await addUserToRole({ user_id: user?.external_id, role: data.role }); - dispatch( - openNotification({ - severity: 'success', - text: `You have successfully added ${user?.first_name} ${user?.last_name} to the role ${USER_COMPOSITE_ROLE.ADMIN.label}`, - }), - ); - } else if (userTypeSelected === USER_COMPOSITE_ROLE.VIEWER.value) { - await addUserToRole({ user_id: user?.external_id, role: data.role }); - dispatch( - openNotification({ - severity: 'success', - text: `You have successfully added ${user?.first_name} ${user?.last_name} to the role ${USER_COMPOSITE_ROLE.VIEWER.label}`, - }), - ); - } else { - await addUserToRole({ user_id: user?.external_id, role: data.role }); - await addTeamMemberToEngagement({ - user_id: user?.external_id, - engagement_id: data.engagement?.id, - }); - dispatch( - openNotification({ - severity: 'success', - text: `You have successfully added ${user?.first_name} ${user?.last_name} as a ${data.role} on ${data.engagement?.name}.`, - }), - ); - } - }; - - const onSubmit: SubmitHandler = async (data: AssignRoleForm) => { - try { - setIsAssigningRole(true); - await assignRoleToUser(data); - setIsAssigningRole(false); - loadUserListing(); - handleClose(); - } catch (error) { - setIsAssigningRole(false); - if (axios.isAxiosError(error)) { - setErrors(error); - } - dispatch( - openNotification({ severity: 'error', text: 'An error occurred while assigning role to the user' }), - ); - } - }; - - const userName = `${user?.first_name || ''} ${user?.last_name || ''}`.trim(); - return ( - - - -
- - - Assign Role to {userName || 'User'} - - - - - - - What role would you like to assign to this user? - - - } - label={'Viewer'} - /> - } - label={'Reviewer'} - /> - } - label={'Team Member'} - /> - } - label={'Administrator'} - /> - - - {String(roleErrors?.message)} - - - - - - - Which engagement would you like to assign{' '} - {user?.first_name + ' ' + user?.last_name} to? - - ( - { - onChange(data); - }} - onInputChange={(_event, newInputValue) => { - debounceLoadEngagements(newInputValue); - }} - renderInput={(params) => ( - - {engagementsLoading && ( - - )} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - getOptionLabel={(engagement: Engagement) => engagement.name} - loading={engagementsLoading} - /> - )} - /> - - - - - - - {backendError} - - - - - - - - - - -
-
-
-
+ setassignRoleModalOpen(false)} + onSaved={loadUserListing} + /> ); }; diff --git a/web/src/components/userManagement/listing/ReassignRoleModal.tsx b/web/src/components/userManagement/listing/ReassignRoleModal.tsx index cd902397d..359a7e709 100644 --- a/web/src/components/userManagement/listing/ReassignRoleModal.tsx +++ b/web/src/components/userManagement/listing/ReassignRoleModal.tsx @@ -1,157 +1,17 @@ -import React, { useContext, useState } from 'react'; -import Modal from '@mui/material/Modal'; -import { FormControl, FormControlLabel, FormLabel, Grid2 as Grid, Paper, Radio, Stack } from '@mui/material'; -import { modalStyle } from 'components/common'; -import { Button } from 'components/common/Input/Button'; -import { BodyText, Heading3 } from 'components/common/Typography'; +import React, { useContext } from 'react'; import { UserManagementContext } from './UserManagementContext'; -import { useForm, FormProvider, Resolver } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as yup from 'yup'; -import ControlledRadioGroup from 'components/common/ControlledInputComponents/ControlledRadioGroup'; -import { USER_COMPOSITE_ROLE } from 'models/user'; -import { Unless } from 'react-if'; -import { Palette } from 'styles/Theme'; -import { changeUserRole } from 'services/userService/api'; -import { useAppDispatch } from 'hooks'; -import { openNotification } from 'services/notificationService/notificationSlice'; - -const schema = yup - .object({ - role: yup.string().required('Please select a role to assign to this user').required(), - }) - .required(); - -type AssignRoleForm = yup.TypeOf; +import { RoleAssignmentModal } from 'components/userManagement/common/RoleAssignmentModal'; export const ReassignRoleModal = () => { const { reassignRoleModalOpen, setReassignRoleModalOpen, user, loadUserListing } = useContext(UserManagementContext); - const [isSaving, setIsSaving] = useState(false); - const dispatch = useAppDispatch(); - - const methods = useForm({ - resolver: yupResolver(schema) as unknown as Resolver, - }); - - const { reset, handleSubmit } = methods; - - const userName = user ? `${user.first_name} ${user.last_name}`.trim() : 'this user'; - - const handleClose = () => { - setReassignRoleModalOpen(false); - reset({}); - }; - - const onSubmit = async (data: AssignRoleForm) => { - try { - const { role } = await schema.validate(data); - setIsSaving(true); - await changeUserRole({ user_id: user.id, role }); - handleClose(); - loadUserListing(); - dispatch( - openNotification({ - severity: 'success', - text: `You have reassigned ${userName} as ${role}.`, - }), - ); - setIsSaving(false); - } catch { - setIsSaving(false); - dispatch(openNotification({ text: 'An error occurred while reassiging role', severity: 'error' })); - } - }; return ( - - - -
- - - Reassign Role for {userName} - - - - - - - - What role would you like to reassign to this user? - - - - } - label={'Viewer'} - /> - - - } - label={'Reviewer'} - /> - - - } - label={'Team Member'} - /> - - - } - label={'Administrator'} - /> - - - - - - - Reassigning a user role will change their role for all active engagements. Any - assigned engagements will have to be reassigned after changing this user's role. - - - - - - - - - -
-
-
-
+ setReassignRoleModalOpen(false)} + onSaved={loadUserListing} + /> ); }; diff --git a/web/src/components/userManagement/userDetails/AddToEngagement.tsx b/web/src/components/userManagement/userDetails/AddToEngagement.tsx index 55110c8d6..055be1d9f 100644 --- a/web/src/components/userManagement/userDetails/AddToEngagement.tsx +++ b/web/src/components/userManagement/userDetails/AddToEngagement.tsx @@ -1,38 +1,25 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import Modal from '@mui/material/Modal'; -import { - Autocomplete, - CircularProgress, - FormControl, - FormControlLabel, - FormHelperText, - FormLabel, - Grid2 as Grid, - Paper, - Radio, - Stack, - TextField, -} from '@mui/material'; +import { FormControl, FormControlLabel, FormHelperText, FormLabel, Grid2 as Grid, Paper, Radio } from '@mui/material'; import { modalStyle } from 'components/common'; import { BodyText, Heading3 } from 'components/common/Typography'; -import { Button } from 'components/common/Input/Button'; import { USER_COMPOSITE_ROLE } from 'models/user'; import { UserDetailsContext } from './UserDetailsContext'; import { useForm, FormProvider, SubmitHandler, Controller, Resolver } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; -import { getEngagements } from 'services/engagementService'; import { addUserToRole } from 'services/userService/api'; import { addTeamMemberToEngagement } from 'services/membershipService'; import { When } from 'react-if'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch } from 'hooks'; -import { debounce } from 'lodash'; -import { Engagement } from 'models/engagement'; import axios, { AxiosError } from 'axios'; import { Palette } from 'styles/Theme'; +import { Engagement } from 'models/engagement'; import ControlledRadioGroup from 'components/common/ControlledInputComponents/ControlledRadioGroup'; import { HTTP_STATUS_CODES } from 'constants/httpResponseCodes'; +import { EngagementAutocomplete } from 'components/userManagement/common/EngagementAutocomplete'; +import { ModalActions } from 'components/userManagement/common/ModalActions'; export const AddToEngagementModal = () => { const { savedUser, addUserModalOpen, setAddUserModalOpen, getUserMemberships, getUserDetails } = @@ -40,7 +27,7 @@ export const AddToEngagementModal = () => { const userHasRole = savedUser?.composite_roles && savedUser?.composite_roles.length > 0; const schema = yup .object({ - engagement: yup.object().nullable(), + engagement: yup.mixed().nullable(), role: yup.string().when([], { is: () => savedUser?.composite_roles.length === 0, then: yup.string().required('A role must be specified'), @@ -53,8 +40,6 @@ export const AddToEngagementModal = () => { const dispatch = useAppDispatch(); const [isAssigningRole, setIsAssigningRole] = useState(false); - const [engagements, setEngagements] = useState([]); - const [engagementsLoading, setEngagementsLoading] = useState(false); const [backendError, setBackendError] = useState(''); const methods = useForm({ @@ -86,37 +71,6 @@ export const AddToEngagementModal = () => { setBackendError(''); }; - const loadEngagements = async (searchText: string) => { - if (searchText.length < 3) { - return; - } - try { - setEngagementsLoading(true); - const response = await getEngagements({ - search_text: searchText, - has_team_access: true, - }); - setEngagements(response.items); - setEngagementsLoading(false); - } catch { - dispatch( - openNotification({ - severity: 'error', - text: 'Error occurred while trying to fetch engagements, please refresh the page or try again at a later time', - }), - ); - setEngagementsLoading(false); - } - }; - - const debounceLoadEngagements = useRef( - debounce((searchText: string) => { - loadEngagements(searchText).catch((error) => { - console.error('Error in debounceLoadEngagements:', error); - }); - }, 1000), - ).current; - const addUserToEngagement = async (data: AddUserForm) => { if (userHasRole) { await addTeamMemberToEngagement({ @@ -251,45 +205,13 @@ export const AddToEngagementModal = () => { ( - { - onChange(data); - }} - onInputChange={(_event, newInputValue) => { - debounceLoadEngagements(newInputValue); - }} - renderInput={(params) => ( - - {engagementsLoading && ( - - )} - {params.InputProps.endAdornment} - - ), - }, - }} - /> - )} - getOptionLabel={(engagement: Engagement) => engagement.name} - loading={engagementsLoading} + render={({ field: { onChange, value } }) => ( + )} /> @@ -303,27 +225,7 @@ export const AddToEngagementModal = () => { - - - - - - - + diff --git a/web/src/components/userManagement/userDetails/UserDetails.tsx b/web/src/components/userManagement/userDetails/UserDetails.tsx index 6c94e1d60..8ccfa5a45 100644 --- a/web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/web/src/components/userManagement/userDetails/UserDetails.tsx @@ -1,108 +1,316 @@ -import React, { JSX, useContext } from 'react'; -import { Grid2 as Grid, Link } from '@mui/material'; -import { useAppSelector } from 'hooks'; +import React, { JSX, ReactNode, useContext, useMemo, useState } from 'react'; +import { Chip, Grid2 as Grid, Link, Paper, Tooltip } from '@mui/material'; +import { useNavigate } from 'react-router'; +import { useAppDispatch, useAppSelector } from 'hooks'; import { UserDetailsContext } from './UserDetailsContext'; import { formatDate } from 'components/common/dateHelper'; import AssignedEngagementsListing from './AssignedEngagementsListing'; import UserStatusButton from './UserStatusButton'; -import UserDetailsSkeleton from './UserDetailsSkeleton'; import { USER_COMPOSITE_ROLE, USER_STATUS } from 'models/user'; import { Button } from 'components/common/Input/Button'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSquareDashedCirclePlus } from '@fortawesome/pro-regular-svg-icons'; +import { faPenToSquare, faSquareDashedCirclePlus, faTrashCan } from '@fortawesome/pro-regular-svg-icons'; import { BodyText } from 'components/common/Typography/Body'; -import { Heading2 } from 'components/common/Typography'; +import { Heading2, Heading3 } from 'components/common/Typography'; +import { deleteUser } from 'services/userService/api'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { USER_ROLES } from 'services/userService/constants'; +import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice'; +import { RoleAssignmentModal } from 'components/userManagement/common/RoleAssignmentModal'; export const UserDetail = ({ label, value }: { label: string; value: JSX.Element }) => { return ( - + {label}: - {value} + + {value} + + + ); +}; + +const ReadOnlyField = ({ label, value }: { label: string; value: ReactNode }) => { + return ( + + + {label} + + + {value} + + + ); +}; + +const EditableField = ({ label, value, controls }: { label: string; value: ReactNode; controls: ReactNode }) => { + return ( + + + + + {label} + + + {value} + + + + {controls} ); }; export const UserDetails = () => { - const { savedUser, setAddUserModalOpen, isUserLoading } = useContext(UserDetailsContext); - const { userDetail } = useAppSelector((state) => state.user); + const { savedUser, setAddUserModalOpen, getUserDetails } = useContext(UserDetailsContext); + const { userDetail, roles } = useAppSelector((state) => state.user); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const [roleModalOpen, setRoleModalOpen] = useState(false); + const [isDeletingUser, setIsDeletingUser] = useState(false); + + const isSelf = savedUser?.id === userDetail?.user?.id; + const isSuperAdmin = roles.includes(USER_ROLES.SUPER_ADMIN); + const isInactive = savedUser?.status_id === USER_STATUS.INACTIVE.value; + const canAssignToEngagement = !isInactive && !isSelf; + const canReassignRole = roles.includes(USER_ROLES.UPDATE_USER_GROUP); + const isAdminUser = savedUser?.main_role === USER_COMPOSITE_ROLE.ADMIN.label; + + const { canEditRole, canEditRoleMessage } = useMemo(() => { + if (!savedUser) return { canEditRole: false, canEditRoleMessage: '' }; + if (isSelf) return { canEditRole: false, canEditRoleMessage: 'You cannot change your own role.' }; + if (isInactive) + return { canEditRole: false, canEditRoleMessage: 'You cannot change the role of an inactive user.' }; + if (!canReassignRole) + return { canEditRole: false, canEditRoleMessage: "You do not have permission to change this user's role." }; + if (isAdminUser && !isSuperAdmin) + return { canEditRole: false, canEditRoleMessage: 'You may not demote an Administrator.' }; + + return { canEditRole: true, canEditRoleMessage: '' }; + }, [savedUser, isSelf, isInactive, isAdminUser, isSuperAdmin, canReassignRole]); + + const canDeleteUser = savedUser?.status_id === USER_STATUS.INACTIVE.value && !isSelf; + const currentStatusLabel = + savedUser?.status_id === USER_STATUS.ACTIVE.value ? USER_STATUS.ACTIVE.label : USER_STATUS.INACTIVE.label; + + const openRoleModal = () => { + if (!savedUser) return; + if (!canEditRole) + return dispatch(openNotification({ severity: 'error', text: 'You need permission to change this role.' })); + setRoleModalOpen(true); + }; - if (isUserLoading) { - return ; - } + const handleDeleteUser = () => { + if (!savedUser) return; + if (!isSuperAdmin) + return dispatch(openNotification({ severity: 'error', text: 'Only Super Admins can delete users.' })); + if (savedUser.status_id !== USER_STATUS.INACTIVE.value) + return dispatch(openNotification({ severity: 'error', text: 'User must be deactivated before deletion.' })); + if (isSelf) + return dispatch(openNotification({ severity: 'error', text: 'You cannot delete your own account.' })); + + dispatch( + openNotificationModal({ + open: true, + data: { + style: 'danger', + header: `Delete ${savedUser.first_name} ${savedUser.last_name}?`, + subText: [ + { text: 'This action removes the user from the database and cannot be undone.' }, + { text: 'Only proceed if you understand the operational and audit implications.' }, + { text: 'Do you want to permanently delete this user?' }, + ], + confirmButtonText: 'Delete User', + cancelButtonText: 'Cancel', + handleConfirm: async () => { + try { + setIsDeletingUser(true); + await deleteUser(savedUser.id); + dispatch( + openNotification({ + severity: 'success', + text: `${savedUser.first_name} ${savedUser.last_name} was deleted successfully.`, + }), + ); + navigate('/usermanagement'); + } catch { + dispatch(openNotification({ severity: 'error', text: 'Failed to delete user.' })); + } finally { + setIsDeletingUser(false); + } + }, + }, + type: 'confirm', + }), + ); + }; return ( - <> - - {/* User Information Section */} - - - - {`${savedUser?.last_name}, ${savedUser?.first_name}`} - - - - + + + + + {`${savedUser?.last_name}, ${savedUser?.first_name}`} + + + + + + Account Details + + + + + {savedUser?.email_address}} + /> + - {savedUser?.email_address}} - /> - {savedUser ? formatDate(savedUser?.created_date) : ''}} - /> - {savedUser?.main_role}} /> - - {savedUser?.status_id === USER_STATUS.ACTIVE.value - ? USER_STATUS.ACTIVE.label - : USER_STATUS.INACTIVE.label} - - } - /> + + + + Access Controls + + + + + + } + controls={ + + {/* wrap the button so the tooltip applies when the button is disabled */} +
+ +
+
+ } + /> - {/* Actions Section */} - - + + } + controls={ + + + + {/* wrap the button so the tooltip applies when the button is disabled */} +
+ +
+
+
+ + {isSuperAdmin && ( + + + {/* wrap the button so the tooltip applies when the button is disabled */} +
+ +
+
+
+ )} +
+ } + /> +
+
+
+ + + + + Assigned Engagements + + + {savedUser?.main_role !== USER_COMPOSITE_ROLE.ADMIN.label && ( + - + )}
- {/* Assigned Engagements Table */} - + - + + setRoleModalOpen(false)} + onSaved={getUserDetails} + /> + ); }; diff --git a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx index 7445dc0be..6d644fa3e 100644 --- a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx +++ b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx @@ -43,28 +43,39 @@ export const UserDetailsContext = createContext({ isMembershipLoading: true, }); -export const UserDetailsContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { +export const UserDetailsContextProvider = ({ + children, + initialUser, +}: { + children: JSX.Element | JSX.Element[]; + initialUser: User; +}) => { const { userId } = useParams(); const navigate = useNavigate(); const dispatch = useAppDispatch(); - const [savedUser, setSavedUser] = useState(createDefaultUser); - const [isUserLoading, setUserLoading] = useState(true); + const [savedUser, setSavedUser] = useState(initialUser); + const [isUserLoading, setUserLoading] = useState(false); const [isMembershipLoading, setMembershipLoading] = useState(true); const [memberships, setMemberships] = useState([]); const [addUserModalOpen, setAddUserModalOpen] = useState(false); useEffect(() => { - fetchUser(); - }, [userId]); + setSavedUser(initialUser); + setUserLoading(false); + }, [initialUser]); - const fetchUser = async () => { + const getUserDetails = async () => { if (isNaN(Number(userId))) { - navigate('/404'); - return Promise.resolve(); + navigate('/usermanagement'); + return; } + setUserLoading(true); try { - await getUserDetails(); + const fetchedUser = await getUser({ user_id: Number(userId), include_roles: true }); + setSavedUser(fetchedUser); + setUserLoading(false); } catch { + setUserLoading(false); dispatch( openNotification({ severity: 'error', @@ -74,29 +85,25 @@ export const UserDetailsContextProvider = ({ children }: { children: JSX.Element } }; - const getUserDetails = async () => { - setUserLoading(true); - const fetchedUser = await getUser({ user_id: Number(userId), include_roles: true }); - setSavedUser(fetchedUser); - setUserLoading(false); - }; - useEffect(() => { getUserMemberships(); - }, [savedUser]); + }, [savedUser?.external_id]); const getUserMemberships = async () => { if (!savedUser) { return; } - const userMemberships = await getMembershipsByUser({ - user_external_id: savedUser.external_id, - include_engagement_details: true, - include_revoked: true, - }); - - setMemberships(userMemberships); - setMembershipLoading(false); + setMembershipLoading(true); + try { + const userMemberships = await getMembershipsByUser({ + user_external_id: savedUser.external_id, + include_engagement_details: true, + include_revoked: true, + }); + setMemberships(userMemberships); + } finally { + setMembershipLoading(false); + } }; return ( diff --git a/web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx b/web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx index 9739851aa..d0da012cc 100644 --- a/web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx +++ b/web/src/components/userManagement/userDetails/UserDetailsSkeleton.tsx @@ -1,43 +1,127 @@ import React from 'react'; -import { Grid2 as Grid, Skeleton } from '@mui/material'; +import { Grid2 as Grid, Paper, Skeleton } from '@mui/material'; export const UserDetailsSkeleton = () => { return ( - <> - - - + + + + + + - - - + + + + - - - + + + + + + + + + - - + + + + + + + + + - - - + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + - - - + + + + + + + + - + - + ); }; diff --git a/web/src/components/userManagement/userDetails/UserStatusButton.tsx b/web/src/components/userManagement/userDetails/UserStatusButton.tsx index 6f0f4487e..92e32b8a8 100644 --- a/web/src/components/userManagement/userDetails/UserStatusButton.tsx +++ b/web/src/components/userManagement/userDetails/UserStatusButton.tsx @@ -7,11 +7,12 @@ import { toggleUserStatus } from 'services/userService/api'; import { USER_ROLES } from 'services/userService/constants'; import { USER_COMPOSITE_ROLE } from 'models/user'; import { Button } from 'components/common/Input/Button'; -import { faUserPlus, faUserSlash } from '@fortawesome/pro-regular-svg-icons'; +import { faUserCheck, faUserXmark } from '@fortawesome/pro-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -const UserStatusButton = () => { +const UserStatusButton = ({ size = 'medium' }: { size?: 'small' | 'medium' | 'large' }) => { const { roles, userDetail } = useAppSelector((state) => state.user); + const { short_name: tenantName } = useAppSelector((state) => state.tenant); const { savedUser, getUserDetails } = useContext(UserDetailsContext); const [userStatus, setUserStatus] = useState(false); const [togglingUserStatus, setTogglingUserStatus] = useState(false); @@ -70,10 +71,11 @@ const UserStatusButton = () => { openNotificationModal({ open: true, data: { - header: `Deactivate ${savedUser?.first_name} ${savedUser?.last_name}`, + style: 'warning', + header: `Deactivate ${savedUser?.first_name} ${savedUser?.last_name} in ${tenantName}?`, subText: [ { - text: `You will be deactivating this user from the system. This user will lose all access to the system.`, + text: `This user will lose all access to resources within this organization. You can reactivate this user at any time to restore their access.`, }, { text: 'Do you want to deactivate this user?', @@ -93,18 +95,17 @@ const UserStatusButton = () => { openNotificationModal({ open: true, data: { - header: `Reactivate ${savedUser?.first_name} ${savedUser?.last_name}`, + style: 'warning', + header: `Reactivate ${savedUser?.first_name} ${savedUser?.last_name}?`, subText: [ { text: - 'You will be Reactivating this user in the system. ' + - 'This user will regain access to the system. Once reactivated, ' + - 'the user will be reassigned to their role and you will have to add them ' + - 'back to engagements if Team Member/Reviewer.', - }, - { - text: 'Do you want to Reactivate this user?', + 'This user will regain access to the system with the ' + + 'same permission level they had before deactivation. ' + + 'You may need to reinstate their access to some engagements manually.', }, + { text: 'Ensure that reactivating this user complies with your organization’s policies.' }, + { text: 'Do you want to reactivate this user?' }, ], handleConfirm: () => { handleUpdateActiveStatus(true); @@ -117,11 +118,13 @@ const UserStatusButton = () => { return ( diff --git a/web/src/components/userManagement/userDetails/index.tsx b/web/src/components/userManagement/userDetails/index.tsx index 88502389d..649d65640 100644 --- a/web/src/components/userManagement/userDetails/index.tsx +++ b/web/src/components/userManagement/userDetails/index.tsx @@ -1,19 +1,38 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { UserDetails } from './UserDetails'; import { AddToEngagementModal } from './AddToEngagement'; import { UserDetailsContextProvider } from './UserDetailsContext'; import { ResponsiveContainer } from 'components/common/Layout'; import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; +import { Await, useLoaderData } from 'react-router'; +import UserDetailsSkeleton from './UserDetailsSkeleton'; +import { UserDetailsLoaderData } from './userDetailsLoader'; +import { User } from 'models/user'; export const UserProfile = () => { + const { user } = useLoaderData() as UserDetailsLoaderData; + return ( - - - - - - - + + + + + } + > + + {(resolvedUser: User) => ( + + + + + + + + )} + + ); }; diff --git a/web/src/components/userManagement/userDetails/userDetailsLoader.tsx b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx new file mode 100644 index 000000000..e6431482b --- /dev/null +++ b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx @@ -0,0 +1,24 @@ +import { Params } from 'react-router'; +import { getUser } from 'services/userService/api'; +import { User } from 'models/user'; + +export interface UserDetailsLoaderData { + user: Promise; +} + +export const userDetailsLoader = ({ params }: { params: Params }): UserDetailsLoaderData => { + const { userId } = params; + + if (!userId || Number.isNaN(Number(userId))) { + throw new Error('User ID is required'); + } + + const user = getUser({ + user_id: Number(userId), + include_roles: true, + }); + + return { user }; +}; + +export default userDetailsLoader; diff --git a/web/src/routes/AuthenticatedRoutes.tsx b/web/src/routes/AuthenticatedRoutes.tsx index fbffee987..8f11d273e 100644 --- a/web/src/routes/AuthenticatedRoutes.tsx +++ b/web/src/routes/AuthenticatedRoutes.tsx @@ -306,11 +306,16 @@ const AuthenticatedRoutes = resolveLazyRouteTree( /> import('components/userManagement/userDetails/userDetailsLoader')} ComponentLazy={() => import('components/userManagement/userDetails')} handle={{ crumb: () => ({ name: 'User Details' }) }} /> - import('routes/Unauthorized')} /> + import('routes/Unauthorized')} + handle={{ crumb: () => ({ name: 'Not Authorized' }) }} + /> import('routes/NotFound')} /> import('routes/NotFound')} /> diff --git a/web/src/routes/NoAccess.tsx b/web/src/routes/NoAccess.tsx index 563558558..b7080ccbf 100644 --- a/web/src/routes/NoAccess.tsx +++ b/web/src/routes/NoAccess.tsx @@ -1,42 +1,61 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faShieldKeyhole } from '@fortawesome/pro-regular-svg-icons'; +import { faArrowRightFromBracket, faRefresh, faShieldKeyhole } from '@fortawesome/pro-regular-svg-icons'; import { Grid2 as Grid } from '@mui/material'; import { ResponsiveContainer } from 'components/common/Layout'; import { BodyText, Heading1 } from 'components/common/Typography'; import React from 'react'; +import { Button } from 'components/common/Input'; +import UserService from 'services/userService'; const NoAccess = () => { return ( - + - - - Access Requested - - - Your IDIR login was successful and an email has been sent to our administrators to request - that you be granted access. Once your request is processed, you'll get a notification email - to confirm you can now access the platform with your credentials. - - - If you think you are seeing this message mistakenly, try reloading the page or contact your - administrator for assistance. - - Thank you. + + Authorization Required + + + Your IDIR login was successful, but you have not been granted access to the page you + requested. You may be looking for another page, your account may still be pending approval, or + your access may have been revoked. + + + If this was your first time accessing the platform, our administrators have been notified of + your request and will review it shortly. Once your request is processed, you'll get a + notification email to confirm you can now access the platform with your credentials. + + + If you think you are seeing this message mistakenly, try reloading the page or contact your + administrator for assistance. + + Thank you. + + + diff --git a/web/src/routes/Unauthorized.tsx b/web/src/routes/Unauthorized.tsx index 443963f16..205731aa0 100644 --- a/web/src/routes/Unauthorized.tsx +++ b/web/src/routes/Unauthorized.tsx @@ -1,43 +1,46 @@ import React from 'react'; import { Grid2 as Grid } from '@mui/material'; import { useNavigate } from 'react-router'; -import { Heading2, Heading4 } from 'components/common/Typography'; +import { BodyText, Heading2 } from 'components/common/Typography'; import { Button } from 'components/common/Input/Button'; +import { ResponsiveContainer } from 'components/common/Layout'; +import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPersonToDoor } from '@fortawesome/pro-regular-svg-icons'; const Unauthorized = () => { const navigate = useNavigate(); return ( - - - - Unauthorized - - - - - You don't have the necessary authorization to view this page. Click the button below to go back - - - - - + + + + + + + + Unauthorized + + + + + You don't have the necessary authorization to view the page you were trying to access. The + button below will return you to the page you were on previously. If you believe that this is an + error and you should have access, please contact your administrator. + + + + + + - + ); }; diff --git a/web/src/services/userService/api/index.tsx b/web/src/services/userService/api/index.tsx index 07aba9368..e2889c60f 100644 --- a/web/src/services/userService/api/index.tsx +++ b/web/src/services/userService/api/index.tsx @@ -80,3 +80,8 @@ export const toggleUserStatus = async (user_id: string, active: boolean): Promis const responseData = await http.PatchRequest(url, data); return responseData.data; }; + +export const deleteUser = async (user_id: number): Promise => { + const url = replaceUrl(Endpoints.User.DELETE, 'user_id', String(user_id)); + await http.DeleteRequest(url); +}; diff --git a/web/src/services/userService/index.ts b/web/src/services/userService/index.ts index 8125f429b..0019b8c6c 100644 --- a/web/src/services/userService/index.ts +++ b/web/src/services/userService/index.ts @@ -6,14 +6,14 @@ import { userRoles, assignedEngagements, } from './userSlice'; -import { Action, AnyAction, Dispatch } from 'redux'; +import { Action, Dispatch } from 'redux'; import { UserDetail } from './types'; import { AppConfig } from 'config'; import Endpoints from 'apiManager/endpoints'; import http from 'apiManager/httpRequestHandler'; import { User } from 'models/user'; import { getMembershipsByUser } from 'services/membershipService'; -import { USER_ROLES, USER_STATUS } from 'services/userService/constants'; +import { USER_ROLES } from 'services/userService/constants'; import { getBaseUrl } from 'helper'; import Keycloak from 'keycloak'; @@ -28,7 +28,7 @@ const setKeycloakInstance = (instance: Keycloak) => { /** * Setting user authentication data in storage */ -const setAuthData = async (dispatch: Dispatch) => { +const setAuthData = async (dispatch: Dispatch) => { try { const authenticated = !!KeycloakData.token; if (!authenticated) { @@ -52,7 +52,7 @@ const setAuthData = async (dispatch: Dispatch) => { const userDetail: UserDetail = await KeycloakData.loadUserInfo(); const updateUserResponse = await updateUser(); - if (updateUserResponse.data && updateUserResponse.data.status_id == USER_STATUS.ACTIVE) { + if (updateUserResponse.data) { userDetail.user = updateUserResponse.data; const engagementsIds = await getAssignedEngagements(userDetail.sub || '', userDetail.user?.roles || []); dispatch(userDetails(userDetail)); diff --git a/web/src/styles/Theme.tsx b/web/src/styles/Theme.tsx index 377e17c6a..10916a857 100644 --- a/web/src/styles/Theme.tsx +++ b/web/src/styles/Theme.tsx @@ -555,8 +555,8 @@ export const AdminTheme = createTheme({ fontSize: '1.25rem', '&[data-testid=RadioButtonCheckedIcon]': { fontSize: '1rem', - marginTop: '0.1rem', - marginLeft: '0.1rem', + marginTop: '0.125rem', + marginLeft: '0.125rem', }, }, '&.Mui-checked': { diff --git a/web/tests/unit/components/user/UserDetails.test.tsx b/web/tests/unit/components/user/UserDetails.test.tsx index 6f2b5f9be..8bca67ef9 100644 --- a/web/tests/unit/components/user/UserDetails.test.tsx +++ b/web/tests/unit/components/user/UserDetails.test.tsx @@ -55,6 +55,9 @@ jest.mock('react-redux', () => ({ userDetail: { ...mockUser1 }, roles: [USER_ROLES.TOGGLE_USER_STATUS], }, + tenant: { + short_name: 'TEST', + }, }), ), useDispatch: jest.fn(() => jest.fn()), @@ -64,6 +67,7 @@ jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), useNavigate: jest.fn(), useParams: jest.fn(() => ({ userId: '1' })), + useLoaderData: jest.fn(() => ({ user: mockUser1 })), })); const router = createMemoryRouter( diff --git a/web/tests/unit/components/user/UserListing.test.tsx b/web/tests/unit/components/user/UserListing.test.tsx index d7ba1fa2c..9efef0f99 100644 --- a/web/tests/unit/components/user/UserListing.test.tsx +++ b/web/tests/unit/components/user/UserListing.test.tsx @@ -35,11 +35,19 @@ jest.mock('@mui/material', () => ({ jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest.fn(() => { - return { - assignedEngagements: [draftEngagement.id], - }; - }), + useSelector: jest.fn((callback) => + callback({ + user: { + assignedEngagements: [draftEngagement.id], + roles: [], + userDetail: { + user: { + id: 999, + }, + }, + }, + }), + ), useDispatch: jest.fn(() => jest.fn()), }));