-
Notifications
You must be signed in to change notification settings - Fork 21
Implement user deletion functionality #2811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e6b48cf
936f006
566b01b
e1adc4c
ad7daa6
9c93b76
85d302e
d601c1f
7c90dcc
8f9f42b
48fd174
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
||
|
|
@@ -78,7 +82,7 @@ def _render_email_template(user: StaffUserModel): | |
| subject = templates['ACCESS_REQUEST']['SUBJECT'] | ||
| grant_access_url = notification.get_tenant_site_url( | ||
| user.tenant_id, paths['USER_MANAGEMENT'] | ||
| ) | ||
| ).replace('{id}', str(user.id)) | ||
| email_environment = templates['ENVIRONMENT'] | ||
| args = { | ||
| 'first_name': user.first_name, | ||
|
|
@@ -170,3 +174,29 @@ def validate_user(db_user: StaffUserModel): | |
| raise BusinessException( | ||
| error='This user is already an Administrator.', | ||
| status_code=HTTPStatus.CONFLICT.value) | ||
|
|
||
| @classmethod | ||
| def delete_deactivated_user(cls, user_id, actor_external_id: Optional[str] = None): | ||
| """Permanently delete a user after required safeguards are satisfied.""" | ||
| db_user = StaffUserModel.get_by_id(user_id, include_inactive=True) | ||
| if db_user is None: | ||
| raise BusinessException(error='User not found.', status_code=HTTPStatus.NOT_FOUND) | ||
|
|
||
| if db_user.status_id != UserStatus.INACTIVE.value: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| 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} | ||
| </> | ||
| ), | ||
| }} | ||
| /> | ||
| )} | ||
| /> | ||
| ); | ||
| }; |
| 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> | ||
| ); |
| 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> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
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?