Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be any cascades for other tables when a user is deleted? Or does the de-activation filter deal with that? For instance, what happens if someone is a project lead, then is deleted?


## April 1, 2026

- **Chore** Change trigger for environment detection [🎟️ DEP-242](https://citz-gdx.atlassian.net/browse/DEP-242)
Expand Down
2 changes: 1 addition & 1 deletion api/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion api/src/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
),
Expand Down
14 changes: 13 additions & 1 deletion api/src/api/resources/staff_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def get():
return jsonify(users), HTTPStatus.OK


@cors_preflight('GET')
@cors_preflight('GET, DELETE')
@API.route('/<user_id>')
class StaffUser(Resource):
"""User controller class."""
Expand All @@ -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('/<user_id>/status')
Expand Down
34 changes: 32 additions & 2 deletions api/src/api/services/staff_user_service.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -78,7 +82,7 @@ def _render_email_template(user: StaffUserModel):
subject = templates['ACCESS_REQUEST']['SUBJECT']
grant_access_url = notification.get_tenant_site_url(
user.tenant_id, paths['USER_MANAGEMENT']
)
).replace('{id}', str(user.id))
email_environment = templates['ENVIRONMENT']
args = {
'first_name': user.first_name,
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we have to do in order to deactivate a user?

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():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, I was wondering it this would be here. We should definitely stop people from deleting theirselves.

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()
1 change: 0 additions & 1 deletion api/tests/unit/models/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,3 @@ def test_update_user_from_dict_valid(session):
new_email = fake.email()
updated_user = StaffUserModel.update_user(new_user.id, {'email_address': new_email})
assert updated_user.email_address == new_email

4 changes: 2 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions web/src/apiManager/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Autocomplete
options={options}
value={value ?? null}
onChange={(_, data) => 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) => (
<TextField
{...params}
fullWidth
placeholder="Search for an engagement"
error={error}
helperText={helperText}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading && <CircularProgress color="primary" size={20} sx={{ marginRight: '2em' }} />}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
);
};
26 changes: 26 additions & 0 deletions web/src/components/userManagement/common/ModalActions.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Grid container size={12} direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: '1em' }}>
<Stack direction={{ md: 'column-reverse', lg: 'row' }} spacing={1} width="100%" justifyContent="flex-end">
<Button onClick={onClose}>{cancelLabel}</Button>
<Button variant="primary" loading={loading} type="submit">
{submitLabel}
</Button>
</Stack>
</Grid>
);
161 changes: 161 additions & 0 deletions web/src/components/userManagement/common/RoleAssignmentModal.tsx
Original file line number Diff line number Diff line change
@@ -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> | 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 (
<Modal open={open} onClose={handleClose} keepMounted={false}>
<Paper sx={{ ...modalStyle }}>
<Grid container spacing={2}>
<Grid size={12}>
<Heading3 bold>{isReassignment ? 'Reassign Role' : 'Assign Role'}</Heading3>
</Grid>
<Grid size={12}>
<BodyText>
Choose a role for {user?.first_name} {user?.last_name}.
</BodyText>
</Grid>
<Grid size={12}>
<Select
fullWidth
value={selectedRole}
onChange={(event) => setSelectedRole(event.target.value)}
>
{roleOptions.map((roleOption) => (
<MenuItem
key={roleOption.value}
value={roleOption.value}
disabled={
roleOption.value === USER_COMPOSITE_ROLE.ADMIN.value &&
!roles.includes(USER_ROLES.SUPER_ADMIN)
}
>
{roleOption.label}
</MenuItem>
))}
</Select>
</Grid>
<Grid container size={12} justifyContent="flex-end" spacing={1}>
<Grid size="auto">
<Button onClick={handleClose} disabled={isSaving}>
Cancel
</Button>
</Grid>
<Grid size="auto">
<Button
variant="primary"
onClick={handleSave}
loading={isSaving}
disabled={!selectedRole || selectedRole === initialRoleValue}
>
Save
</Button>
</Grid>
</Grid>
</Grid>
</Paper>
</Modal>
);
};
Loading
Loading