From e6b48cff7a23cb32774478d193eaff63271862c8 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 7 Apr 2026 17:27:44 -0700 Subject: [PATCH 1/9] Implement user deletion functionality --- CHANGELOG.MD | 8 + api/src/api/resources/staff_user.py | 14 +- api/src/api/services/staff_user_service.py | 32 ++- web/src/apiManager/endpoints/index.ts | 1 + .../common/RoleAssignmentModal.tsx | 161 +++++++++++++ .../userDetails/UserDetails.tsx | 226 ++++++++++++++---- .../userDetails/UserDetailsContext.tsx | 34 +-- .../userDetails/userDetailsLoader.tsx | 19 ++ web/src/routes/AuthenticatedRoutes.tsx | 1 + web/src/services/userService/api/index.tsx | 5 + 10 files changed, 435 insertions(+), 66 deletions(-) create mode 100644 web/src/components/userManagement/common/RoleAssignmentModal.tsx create mode 100644 web/src/components/userManagement/userDetails/userDetailsLoader.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 59f69c88f..d14049e6b 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. + ## March 31, 2026 - **Chore** OpenShift and Helm value changes from MET to DEP [🎟️ DEP-231](https://citz-gdx.atlassian.net/browse/DEP-231) 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..bf44f6efc 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 @@ -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/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/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/userDetails/UserDetails.tsx b/web/src/components/userManagement/userDetails/UserDetails.tsx index 6c94e1d60..9562879d3 100644 --- a/web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/web/src/components/userManagement/userDetails/UserDetails.tsx @@ -1,6 +1,7 @@ -import React, { JSX, useContext } from 'react'; -import { Grid2 as Grid, Link } from '@mui/material'; -import { useAppSelector } from 'hooks'; +import React, { JSX, useContext, useMemo, useState } from 'react'; +import { 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'; @@ -9,9 +10,14 @@ 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 ( @@ -25,41 +31,110 @@ export const UserDetail = ({ label, value }: { label: string; value: JSX.Element }; export const UserDetails = () => { - const { savedUser, setAddUserModalOpen, isUserLoading } = useContext(UserDetailsContext); - const { userDetail } = useAppSelector((state) => state.user); + const { savedUser, setAddUserModalOpen, isUserLoading, 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 (isAdminUser && !isSuperAdmin) + return { canEditRole: false, canEditRoleMessage: 'You may not demote an Administrator.' }; + if (!canReassignRole) + return { canEditRole: false, canEditRoleMessage: "You do not have permission to change this user's role." }; + + return { canEditRole: true, canEditRoleMessage: '' }; + }, [savedUser, isSelf, isInactive, isAdminUser, isSuperAdmin, canReassignRole]); + + const canDeleteUser = savedUser?.status_id === USER_STATUS.INACTIVE.value && !isSelf; if (isUserLoading) { return ; } + const openRoleModal = () => { + if (!savedUser) return; + if (!canEditRole) + return dispatch(openNotification({ severity: 'error', text: 'You need permission to change this role.' })); + setRoleModalOpen(true); + }; + + 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}`} + + {savedUser?.email_address}} + value={{savedUser?.email_address}} /> {savedUser ? formatDate(savedUser?.created_date) : ''}} /> - {savedUser?.main_role}} /> + {savedUser?.main_role || 'Unassigned'}} /> { } /> + - {/* Actions Section */} - - - + + )} + + {/* describeChild: Tooltip *describes* its children rather than labeling them, using aria-describedby instead of aria-label under the hood*/} + + {/* wrap the button so the tooltip applies when the button is disabled */} +
+ +
+
+
+ + - Add to an Engagement - + {/* 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 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..585ddec17 100644 --- a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx +++ b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useState, useEffect, JSX } from 'react'; import { useNavigate, useParams } from 'react-router'; +import { useLoaderData } from 'react-router'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; import { User, createDefaultUser } from 'models/user'; @@ -23,6 +24,10 @@ export type UserParams = { userId: string; }; +interface UserDetailsLoaderData { + user: User; +} + export const UserDetailsContext = createContext({ savedUser: createDefaultUser, isUserLoading: true, @@ -46,25 +51,31 @@ export const UserDetailsContext = createContext({ export const UserDetailsContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { const { userId } = useParams(); const navigate = useNavigate(); + const { user } = useLoaderData() as UserDetailsLoaderData; const dispatch = useAppDispatch(); - const [savedUser, setSavedUser] = useState(createDefaultUser); - const [isUserLoading, setUserLoading] = useState(true); + const [savedUser, setSavedUser] = useState(user); + const [isUserLoading, setUserLoading] = useState(false); const [isMembershipLoading, setMembershipLoading] = useState(true); const [memberships, setMemberships] = useState([]); const [addUserModalOpen, setAddUserModalOpen] = useState(false); useEffect(() => { - fetchUser(); - }, [userId]); + setSavedUser(user); + setUserLoading(false); + }, [user]); - 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,13 +85,6 @@ 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]); diff --git a/web/src/components/userManagement/userDetails/userDetailsLoader.tsx b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx new file mode 100644 index 000000000..2fe17beb0 --- /dev/null +++ b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx @@ -0,0 +1,19 @@ +import { Params } from 'react-router'; +import { getUser } from 'services/userService/api'; + +export const userDetailsLoader = async ({ params }: { params: Params }) => { + const { userId } = params; + + if (!userId || Number.isNaN(Number(userId))) { + throw new Error('User ID is required'); + } + + const user = await 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..e79c75974 100644 --- a/web/src/routes/AuthenticatedRoutes.tsx +++ b/web/src/routes/AuthenticatedRoutes.tsx @@ -306,6 +306,7 @@ const AuthenticatedRoutes = resolveLazyRouteTree( /> import('components/userManagement/userDetails/userDetailsLoader')} ComponentLazy={() => import('components/userManagement/userDetails')} handle={{ crumb: () => ({ name: 'User Details' }) }} /> 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); +}; From 566b01bd1fa32652477ef4adebb24470b95e8be8 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 7 Apr 2026 17:31:52 -0700 Subject: [PATCH 2/9] Fix sonarcloud issue --- .../userManagement/userDetails/UserDetailsContext.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx index 585ddec17..ec3a5ad14 100644 --- a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx +++ b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx @@ -1,6 +1,5 @@ import React, { createContext, useState, useEffect, JSX } from 'react'; -import { useNavigate, useParams } from 'react-router'; -import { useLoaderData } from 'react-router'; +import { useNavigate, useParams, useLoaderData } from 'react-router'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; import { User, createDefaultUser } from 'models/user'; From e1adc4c1ae0d5cc462913fb99b9bca3c96d734bf Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 7 Apr 2026 17:52:24 -0700 Subject: [PATCH 3/9] Fix lint issue --- .../common/RoleAssignmentModal.tsx | 161 ------------------ .../userDetails/UserDetails.tsx | 35 +--- 2 files changed, 1 insertion(+), 195 deletions(-) delete mode 100644 web/src/components/userManagement/common/RoleAssignmentModal.tsx diff --git a/web/src/components/userManagement/common/RoleAssignmentModal.tsx b/web/src/components/userManagement/common/RoleAssignmentModal.tsx deleted file mode 100644 index 63dc2a4a9..000000000 --- a/web/src/components/userManagement/common/RoleAssignmentModal.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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/userDetails/UserDetails.tsx b/web/src/components/userManagement/userDetails/UserDetails.tsx index 9562879d3..8ef9a9f57 100644 --- a/web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/web/src/components/userManagement/userDetails/UserDetails.tsx @@ -10,14 +10,13 @@ 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 { faPenToSquare, faSquareDashedCirclePlus, faTrashCan } from '@fortawesome/pro-regular-svg-icons'; +import { faSquareDashedCirclePlus, faTrashCan } from '@fortawesome/pro-regular-svg-icons'; import { BodyText } from 'components/common/Typography/Body'; 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 ( @@ -36,7 +35,6 @@ export const UserDetails = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const [roleModalOpen, setRoleModalOpen] = useState(false); const [isDeletingUser, setIsDeletingUser] = useState(false); const isSelf = savedUser?.id === userDetail?.user?.id; @@ -65,13 +63,6 @@ export const UserDetails = () => { return ; } - const openRoleModal = () => { - if (!savedUser) return; - if (!canEditRole) - return dispatch(openNotification({ severity: 'error', text: 'You need permission to change this role.' })); - setRoleModalOpen(true); - }; - const handleDeleteUser = () => { if (!savedUser) return; if (!isSuperAdmin) @@ -164,23 +155,6 @@ export const UserDetails = () => {
)} - - {/* describeChild: Tooltip *describes* its children rather than labeling them, using aria-describedby instead of aria-label under the hood*/} - - {/* wrap the button so the tooltip applies when the button is disabled */} -
- -
-
-
{ - - setRoleModalOpen(false)} - onSaved={getUserDetails} - /> ); }; From ad7daa6084c985755b7cd5b4953c9cd342a24b95 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 7 Apr 2026 17:57:47 -0700 Subject: [PATCH 4/9] Fix lint issue --- .../userManagement/userDetails/UserDetails.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/web/src/components/userManagement/userDetails/UserDetails.tsx b/web/src/components/userManagement/userDetails/UserDetails.tsx index 8ef9a9f57..006fba730 100644 --- a/web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/web/src/components/userManagement/userDetails/UserDetails.tsx @@ -30,7 +30,7 @@ export const UserDetail = ({ label, value }: { label: string; value: JSX.Element }; export const UserDetails = () => { - const { savedUser, setAddUserModalOpen, isUserLoading, getUserDetails } = useContext(UserDetailsContext); + const { savedUser, setAddUserModalOpen, isUserLoading } = useContext(UserDetailsContext); const { userDetail, roles } = useAppSelector((state) => state.user); const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -44,19 +44,6 @@ export const UserDetails = () => { 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 (isAdminUser && !isSuperAdmin) - return { canEditRole: false, canEditRoleMessage: 'You may not demote an Administrator.' }; - if (!canReassignRole) - return { canEditRole: false, canEditRoleMessage: "You do not have permission to change this user's role." }; - - return { canEditRole: true, canEditRoleMessage: '' }; - }, [savedUser, isSelf, isInactive, isAdminUser, isSuperAdmin, canReassignRole]); - const canDeleteUser = savedUser?.status_id === USER_STATUS.INACTIVE.value && !isSelf; if (isUserLoading) { From 9c93b76a03afa9ea8cd25a8d97f83b1d3f31769a Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 7 Apr 2026 18:00:12 -0700 Subject: [PATCH 5/9] Fix lint issue --- web/src/components/userManagement/userDetails/UserDetails.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/components/userManagement/userDetails/UserDetails.tsx b/web/src/components/userManagement/userDetails/UserDetails.tsx index 006fba730..738891f9d 100644 --- a/web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/web/src/components/userManagement/userDetails/UserDetails.tsx @@ -1,4 +1,4 @@ -import React, { JSX, useContext, useMemo, useState } from 'react'; +import React, { JSX, useContext, useState } from 'react'; import { Grid2 as Grid, Link, Paper, Tooltip } from '@mui/material'; import { useNavigate } from 'react-router'; import { useAppDispatch, useAppSelector } from 'hooks'; @@ -41,7 +41,6 @@ export const UserDetails = () => { 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 canDeleteUser = savedUser?.status_id === USER_STATUS.INACTIVE.value && !isSelf; From 85d302e34031ad58b9e3a94923e9803f80848e59 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 7 Apr 2026 18:13:29 -0700 Subject: [PATCH 6/9] Fix user details page test --- web/tests/unit/components/user/UserDetails.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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( From d601c1fc808cc732a8d5067b8b1399c339e6e467 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 8 Apr 2026 16:42:10 -0700 Subject: [PATCH 7/9] Update role assignment + logged out user display --- api/sample.env | 2 +- api/src/api/config.py | 2 +- api/src/api/services/staff_user_service.py | 2 +- api/tests/unit/models/test_user.py | 30 ++ web/src/App.tsx | 4 +- .../common/EngagementAutocomplete.tsx | 55 +++ .../userManagement/common/ModalActions.tsx | 26 ++ .../common/RoleAssignmentModal.tsx | 161 +++++++++ .../common/useEngagementSearch.ts | 47 +++ .../listing/ActionsDropDown.tsx | 2 + .../userManagement/listing/AddUserModal.tsx | 112 +----- .../listing/AssignRoleModal.tsx | 322 +----------------- .../listing/ReassignRoleModal.tsx | 156 +-------- .../userDetails/AddToEngagement.tsx | 126 +------ .../userDetails/UserDetails.tsx | 293 +++++++++++----- .../userDetails/UserDetailsContext.tsx | 42 +-- .../userDetails/UserDetailsSkeleton.tsx | 130 +++++-- .../userDetails/UserStatusButton.tsx | 29 +- .../userManagement/userDetails/index.tsx | 35 +- .../userDetails/userDetailsLoader.tsx | 9 +- web/src/routes/AuthenticatedRoutes.tsx | 6 +- web/src/routes/NoAccess.tsx | 71 ++-- web/src/routes/Unauthorized.tsx | 63 ++-- web/src/services/userService/index.ts | 6 +- web/src/styles/Theme.tsx | 4 +- .../unit/components/user/UserListing.test.tsx | 18 +- 26 files changed, 860 insertions(+), 893 deletions(-) create mode 100644 web/src/components/userManagement/common/EngagementAutocomplete.tsx create mode 100644 web/src/components/userManagement/common/ModalActions.tsx create mode 100644 web/src/components/userManagement/common/RoleAssignmentModal.tsx create mode 100644 web/src/components/userManagement/common/useEngagementSearch.ts 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/services/staff_user_service.py b/api/src/api/services/staff_user_service.py index bf44f6efc..22c4968b7 100644 --- a/api/src/api/services/staff_user_service.py +++ b/api/src/api/services/staff_user_service.py @@ -82,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, diff --git a/api/tests/unit/models/test_user.py b/api/tests/unit/models/test_user.py index 6b4c76841..a992ed266 100644 --- a/api/tests/unit/models/test_user.py +++ b/api/tests/unit/models/test_user.py @@ -16,7 +16,10 @@ Test suite to ensure that the User model routines are working as expected. """ +from unittest.mock import patch + from faker import Faker +from sqlalchemy.exc import IntegrityError from api.models.staff_user import StaffUser as StaffUserModel @@ -88,3 +91,30 @@ 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 + + +def test_create_user_retries_when_staff_user_sequence_is_stale(session): + """Assert that user creation retries after realigning a stale PostgreSQL sequence.""" + first_name = fake.name() + external_id = str(fake.random_number(digits=5)) + user_dict = { + 'first_name': first_name, + 'middle_name': fake.name(), + 'last_name': fake.name(), + 'email_address': fake.email(), + 'external_id': external_id, + } + integrity_error = IntegrityError( + 'INSERT INTO staff_users ...', + {}, + Exception('duplicate key value violates unique constraint "staff_users_pkey"'), + ) + + with patch.object(StaffUserModel, 'save', side_effect=[integrity_error, None]) as save_mock: + with patch.object(StaffUserModel, '_reset_primary_key_sequence') as reset_mock: + new_user = StaffUserModel.create_user(user_dict) + + assert type(new_user) is StaffUserModel + assert new_user.external_id == external_id + assert save_mock.call_count == 2 + reset_mock.assert_called_once() 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/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 738891f9d..8ccfa5a45 100644 --- a/web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/web/src/components/userManagement/userDetails/UserDetails.tsx @@ -1,53 +1,118 @@ -import React, { JSX, useContext, useState } from 'react'; -import { Grid2 as Grid, Link, Paper, Tooltip } from '@mui/material'; +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, faTrashCan } 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, 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 { 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; - if (isUserLoading) { - return ; - } + const openRoleModal = () => { + if (!savedUser) return; + if (!canEditRole) + return dispatch(openNotification({ severity: 'error', text: 'You need permission to change this role.' })); + setRoleModalOpen(true); + }; const handleDeleteUser = () => { if (!savedUser) return; @@ -96,93 +161,155 @@ export const UserDetails = () => { return ( - + {`${savedUser?.last_name}, ${savedUser?.first_name}`} - - {savedUser?.email_address}} - /> - {savedUser ? formatDate(savedUser?.created_date) : ''}} - /> - {savedUser?.main_role || 'Unassigned'}} /> - - {savedUser?.status_id === USER_STATUS.ACTIVE.value - ? USER_STATUS.ACTIVE.label - : USER_STATUS.INACTIVE.label} - - } - /> + + + + Account Details + + + + + {savedUser?.email_address}} + /> + + + + + + + Access Controls + + + + + + } + controls={ + + {/* wrap the button so the tooltip applies when the button is disabled */} +
+ +
+
+ } + /> + + + } + 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 */} +
+ +
+
+
+ )} +
+ } + /> +
- - Actions - - {savedUser?.main_role !== USER_COMPOSITE_ROLE.ADMIN.label && ( - - - - )} - - + + + Assigned Engagements + + + {savedUser?.main_role !== USER_COMPOSITE_ROLE.ADMIN.label && ( + + - {isSuperAdmin && ( - - - {/* wrap the button so the tooltip applies when the button is disabled */} -
- -
-
-
- )} -
+ )}
- + + + setRoleModalOpen(false)} + onSaved={getUserDetails} + /> ); }; diff --git a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx index ec3a5ad14..6d644fa3e 100644 --- a/web/src/components/userManagement/userDetails/UserDetailsContext.tsx +++ b/web/src/components/userManagement/userDetails/UserDetailsContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useState, useEffect, JSX } from 'react'; -import { useNavigate, useParams, useLoaderData } from 'react-router'; +import { useNavigate, useParams } from 'react-router'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; import { User, createDefaultUser } from 'models/user'; @@ -23,10 +23,6 @@ export type UserParams = { userId: string; }; -interface UserDetailsLoaderData { - user: User; -} - export const UserDetailsContext = createContext({ savedUser: createDefaultUser, isUserLoading: true, @@ -47,21 +43,26 @@ 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 { user } = useLoaderData() as UserDetailsLoaderData; const dispatch = useAppDispatch(); - const [savedUser, setSavedUser] = useState(user); + 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(() => { - setSavedUser(user); + setSavedUser(initialUser); setUserLoading(false); - }, [user]); + }, [initialUser]); const getUserDetails = async () => { if (isNaN(Number(userId))) { @@ -86,20 +87,23 @@ export const UserDetailsContextProvider = ({ children }: { children: JSX.Element 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 index 2fe17beb0..e6431482b 100644 --- a/web/src/components/userManagement/userDetails/userDetailsLoader.tsx +++ b/web/src/components/userManagement/userDetails/userDetailsLoader.tsx @@ -1,14 +1,19 @@ import { Params } from 'react-router'; import { getUser } from 'services/userService/api'; +import { User } from 'models/user'; -export const userDetailsLoader = async ({ params }: { params: Params }) => { +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 = await getUser({ + const user = getUser({ user_id: Number(userId), include_roles: true, }); diff --git a/web/src/routes/AuthenticatedRoutes.tsx b/web/src/routes/AuthenticatedRoutes.tsx index e79c75974..8f11d273e 100644 --- a/web/src/routes/AuthenticatedRoutes.tsx +++ b/web/src/routes/AuthenticatedRoutes.tsx @@ -311,7 +311,11 @@ const AuthenticatedRoutes = resolveLazyRouteTree( 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/index.ts b/web/src/services/userService/index.ts index 8125f429b..9ddad3c0d 100644 --- a/web/src/services/userService/index.ts +++ b/web/src/services/userService/index.ts @@ -6,7 +6,7 @@ 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'; @@ -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/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()), })); From 7c90dcc5550237670feb2906e1038c4cadcce4c4 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 8 Apr 2026 16:51:38 -0700 Subject: [PATCH 8/9] Fix lint error in web --- web/src/services/userService/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/services/userService/index.ts b/web/src/services/userService/index.ts index 9ddad3c0d..0019b8c6c 100644 --- a/web/src/services/userService/index.ts +++ b/web/src/services/userService/index.ts @@ -13,7 +13,7 @@ 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'; From 8f9f42b9a801f02b44df7d51c749410464e09aa8 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 8 Apr 2026 16:56:04 -0700 Subject: [PATCH 9/9] Remove nonfunctional test --- api/tests/unit/models/test_user.py | 32 +----------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/api/tests/unit/models/test_user.py b/api/tests/unit/models/test_user.py index a992ed266..24215e9a7 100644 --- a/api/tests/unit/models/test_user.py +++ b/api/tests/unit/models/test_user.py @@ -16,10 +16,7 @@ Test suite to ensure that the User model routines are working as expected. """ -from unittest.mock import patch - from faker import Faker -from sqlalchemy.exc import IntegrityError from api.models.staff_user import StaffUser as StaffUserModel @@ -90,31 +87,4 @@ def test_update_user_from_dict_valid(session): assert new_user.email_address == old_email new_email = fake.email() updated_user = StaffUserModel.update_user(new_user.id, {'email_address': new_email}) - assert updated_user.email_address == new_email - - -def test_create_user_retries_when_staff_user_sequence_is_stale(session): - """Assert that user creation retries after realigning a stale PostgreSQL sequence.""" - first_name = fake.name() - external_id = str(fake.random_number(digits=5)) - user_dict = { - 'first_name': first_name, - 'middle_name': fake.name(), - 'last_name': fake.name(), - 'email_address': fake.email(), - 'external_id': external_id, - } - integrity_error = IntegrityError( - 'INSERT INTO staff_users ...', - {}, - Exception('duplicate key value violates unique constraint "staff_users_pkey"'), - ) - - with patch.object(StaffUserModel, 'save', side_effect=[integrity_error, None]) as save_mock: - with patch.object(StaffUserModel, '_reset_primary_key_sequence') as reset_mock: - new_user = StaffUserModel.create_user(user_dict) - - assert type(new_user) is StaffUserModel - assert new_user.external_id == external_id - assert save_mock.call_count == 2 - reset_mock.assert_called_once() + assert updated_user.email_address == new_email \ No newline at end of file