Skip to content

Commit e6b48cf

Browse files
committed
Implement user deletion functionality
1 parent 5e171e3 commit e6b48cf

10 files changed

Lines changed: 435 additions & 66 deletions

File tree

CHANGELOG.MD

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## April 7, 2026
2+
3+
- **Feature** Add "delete user" functionality to user management section [🎟️ DEP-239](https://citz-gdx.atlassian.net/browse/DEP-239)
4+
- Added a "Delete User" button to the user details page in the admin view, visible only to super admins.
5+
- 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).
6+
- Added success and error messages to provide feedback on the deletion process.
7+
- Reworked the user details component to rearrange the existing layout and accommodate the new delete button, while ensuring a clear and intuitive user experience.
8+
19
## March 31, 2026
210

311
- **Chore** OpenShift and Helm value changes from MET to DEP [🎟️ DEP-231](https://citz-gdx.atlassian.net/browse/DEP-231)

api/src/api/resources/staff_user.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def get():
8686
return jsonify(users), HTTPStatus.OK
8787

8888

89-
@cors_preflight('GET')
89+
@cors_preflight('GET, DELETE')
9090
@API.route('/<user_id>')
9191
class StaffUser(Resource):
9292
"""User controller class."""
@@ -104,6 +104,18 @@ def get(user_id):
104104
)
105105
return user, HTTPStatus.OK
106106

107+
@staticmethod
108+
@cross_origin(origins=allowedorigins())
109+
@require_role([Role.SUPER_ADMIN.value])
110+
def delete(user_id):
111+
"""Permanently delete an inactive user."""
112+
try:
113+
user_data = TokenInfo.get_user_data()
114+
StaffUserService.delete_deactivated_user(user_id, user_data.get('external_id'))
115+
return {'message': 'User deleted successfully.'}, HTTPStatus.OK
116+
except BusinessException as err:
117+
return {'message': err.error}, err.status_code
118+
107119

108120
@cors_preflight('PATCH')
109121
@API.route('/<user_id>/status')

api/src/api/services/staff_user_service.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
"""Service for user management."""
22
from http import HTTPStatus
3+
from typing import Optional
34

45
from flask import current_app, g
56

67
from api.exceptions.business_exception import BusinessException
8+
from api.models.db import db
9+
from api.models.membership import Membership
710
from api.models.pagination_options import PaginationOptions
811
from api.models.staff_user import StaffUser as StaffUserModel
12+
from api.models.user_group_membership import UserGroupMembership
913
from api.schemas.staff_user import StaffUserSchema
1014
from api.services.user_group_membership_service import UserGroupMembershipService
1115
from api.utils import notification
1216
from api.utils.constants import CompositeRoles
13-
from api.utils.enums import CompositeRoleId, CompositeRoleNames
17+
from api.utils.enums import CompositeRoleId, CompositeRoleNames, UserStatus
1418
from api.utils.template import Template
1519

1620

@@ -170,3 +174,29 @@ def validate_user(db_user: StaffUserModel):
170174
raise BusinessException(
171175
error='This user is already an Administrator.',
172176
status_code=HTTPStatus.CONFLICT.value)
177+
178+
@classmethod
179+
def delete_deactivated_user(cls, user_id, actor_external_id: Optional[str] = None):
180+
"""Permanently delete a user after required safeguards are satisfied."""
181+
db_user = StaffUserModel.get_by_id(user_id, include_inactive=True)
182+
if db_user is None:
183+
raise BusinessException(error='User not found.', status_code=HTTPStatus.NOT_FOUND)
184+
185+
if db_user.status_id != UserStatus.INACTIVE.value:
186+
raise BusinessException(
187+
error='User must be deactivated before deletion.',
188+
status_code=HTTPStatus.BAD_REQUEST,
189+
)
190+
191+
if actor_external_id and db_user.external_id.lower() == actor_external_id.lower():
192+
raise BusinessException(
193+
error='You cannot delete your own account.',
194+
status_code=HTTPStatus.CONFLICT,
195+
)
196+
197+
Membership.query.filter(Membership.user_id == db_user.id).delete(synchronize_session=False)
198+
UserGroupMembership.query.filter(
199+
UserGroupMembership.staff_user_external_id == db_user.external_id,
200+
).delete(synchronize_session=False)
201+
db.session.delete(db_user)
202+
db.session.commit()

web/src/apiManager/endpoints/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const Endpoints = {
4646
},
4747
User: {
4848
GET: `${AppConfig.apiUrl}/user/user_id`,
49+
DELETE: `${AppConfig.apiUrl}/user/user_id`,
4950
CREATE_UPDATE: `${AppConfig.apiUrl}/user/`,
5051
GET_LIST: `${AppConfig.apiUrl}/user/`,
5152
ADD_TO_COMPOSITE_ROLE: `${AppConfig.apiUrl}/user/user_id/roles`,
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React, { useEffect, useMemo, useState } from 'react';
2+
import { Grid2 as Grid, MenuItem, Modal, Paper, Select } from '@mui/material';
3+
import { Button } from 'components/common/Input/Button';
4+
import { BodyText, Heading3 } from 'components/common/Typography';
5+
import { modalStyle } from 'components/common';
6+
import { useAppDispatch, useAppSelector } from 'hooks';
7+
import { USER_COMPOSITE_ROLE, User } from 'models/user';
8+
import { addUserToRole, changeUserRole } from 'services/userService/api';
9+
import { openNotification } from 'services/notificationService/notificationSlice';
10+
import { USER_ROLES } from 'services/userService/constants';
11+
12+
const roleOptions = [
13+
USER_COMPOSITE_ROLE.ADMIN,
14+
USER_COMPOSITE_ROLE.VIEWER,
15+
USER_COMPOSITE_ROLE.TEAM_MEMBER,
16+
USER_COMPOSITE_ROLE.REVIEWER,
17+
];
18+
19+
interface RoleAssignmentModalProps {
20+
open: boolean;
21+
user?: User;
22+
onClose: () => void;
23+
onSaved?: () => Promise<void> | void;
24+
}
25+
26+
export const RoleAssignmentModal = ({ open, user, onClose, onSaved }: RoleAssignmentModalProps) => {
27+
const dispatch = useAppDispatch();
28+
const { roles } = useAppSelector((state) => state.user);
29+
const [selectedRole, setSelectedRole] = useState('');
30+
const [isSaving, setIsSaving] = useState(false);
31+
32+
const isSuperAdmin = roles.includes(USER_ROLES.SUPER_ADMIN);
33+
const isAdminUser = user?.main_role === USER_COMPOSITE_ROLE.ADMIN.label;
34+
const isReassignment = Boolean(user?.main_role);
35+
36+
const initialRoleValue = useMemo(
37+
() => roleOptions.find((role) => role.label === user?.main_role)?.value || '',
38+
[user?.main_role],
39+
);
40+
41+
useEffect(() => {
42+
if (!open) {
43+
setSelectedRole('');
44+
return;
45+
}
46+
47+
setSelectedRole(initialRoleValue);
48+
}, [initialRoleValue, open]);
49+
50+
const handleClose = () => {
51+
if (isSaving) {
52+
return;
53+
}
54+
55+
setSelectedRole('');
56+
onClose();
57+
};
58+
59+
const handleSave = async () => {
60+
if (!user || !selectedRole) {
61+
return;
62+
}
63+
64+
if (isAdminUser && !isSuperAdmin) {
65+
dispatch(
66+
openNotification({
67+
severity: 'error',
68+
text: 'Only Super Admins can reassign an Administrator to a lower role.',
69+
}),
70+
);
71+
return;
72+
}
73+
74+
try {
75+
setIsSaving(true);
76+
77+
if (user.main_role) {
78+
await changeUserRole({
79+
user_id: user.id,
80+
role: selectedRole,
81+
});
82+
} else {
83+
await addUserToRole({
84+
user_id: user.external_id,
85+
role: selectedRole,
86+
});
87+
}
88+
89+
await onSaved?.();
90+
handleClose();
91+
92+
const selectedRoleLabel = roleOptions.find((role) => role.value === selectedRole)?.label || selectedRole;
93+
dispatch(
94+
openNotification({
95+
severity: 'success',
96+
text: isReassignment
97+
? `You have reassigned ${user.first_name} ${user.last_name} as ${selectedRoleLabel}.`
98+
: `You have successfully added ${user.first_name} ${user.last_name} to the role ${selectedRoleLabel}.`,
99+
}),
100+
);
101+
} catch {
102+
dispatch(openNotification({ severity: 'error', text: 'Failed to update user role.' }));
103+
} finally {
104+
setIsSaving(false);
105+
}
106+
};
107+
108+
return (
109+
<Modal open={open} onClose={handleClose} keepMounted={false}>
110+
<Paper sx={{ ...modalStyle }}>
111+
<Grid container spacing={2}>
112+
<Grid size={12}>
113+
<Heading3 bold>{isReassignment ? 'Reassign Role' : 'Assign Role'}</Heading3>
114+
</Grid>
115+
<Grid size={12}>
116+
<BodyText>
117+
Choose a role for {user?.first_name} {user?.last_name}.
118+
</BodyText>
119+
</Grid>
120+
<Grid size={12}>
121+
<Select
122+
fullWidth
123+
value={selectedRole}
124+
onChange={(event) => setSelectedRole(event.target.value)}
125+
>
126+
{roleOptions.map((roleOption) => (
127+
<MenuItem
128+
key={roleOption.value}
129+
value={roleOption.value}
130+
disabled={
131+
roleOption.value === USER_COMPOSITE_ROLE.ADMIN.value &&
132+
!roles.includes(USER_ROLES.SUPER_ADMIN)
133+
}
134+
>
135+
{roleOption.label}
136+
</MenuItem>
137+
))}
138+
</Select>
139+
</Grid>
140+
<Grid container size={12} justifyContent="flex-end" spacing={1}>
141+
<Grid size="auto">
142+
<Button onClick={handleClose} disabled={isSaving}>
143+
Cancel
144+
</Button>
145+
</Grid>
146+
<Grid size="auto">
147+
<Button
148+
variant="primary"
149+
onClick={handleSave}
150+
loading={isSaving}
151+
disabled={!selectedRole || selectedRole === initialRoleValue}
152+
>
153+
Save
154+
</Button>
155+
</Grid>
156+
</Grid>
157+
</Grid>
158+
</Paper>
159+
</Modal>
160+
);
161+
};

0 commit comments

Comments
 (0)