feat(admin/users): reactivate + delete actions for deactivated admin users

#574 follow-up — @blazmaric flagged that once an admin user is
deactivated, the UI loses every affordance to manage that record.
The deactivate button hides (rightly — they're already deactivated)
but nothing replaces it, leaving the row stranded in the list with
no path to either restore access or permanently remove it.

## Backend

New on `userManagementService`:

- **`activateAdminUser(id, activatedById)`** — symmetric to
  `deactivateAdminUser`. Flips `is_active` back to true, logs
  `admin_user_activated` activity. Idempotent: already-active target
  short-circuits without bumping `updated_at`. No "can't activate
  yourself" guard needed (actor is by definition already active).
- **`deleteAdminUser(id, deletedById)`** — hard-deletes the row.
  Same self-action and last-super-admin guards as deactivate.
  Last-super-admin guard counts ACTIVE super admins excluding the
  target — so an already-deactivated super_admin can still be
  deleted when an active super_admin remains. FK ON DELETE rules
  in core migrations handle the cascade: SET NULL on
  `created_by_admin_id` everywhere (events, photos, quotes,
  invoices, contracts, customer_accounts, …); CASCADE on the
  user's own `api_tokens` + their pending admin / customer
  invitations.

New routes on `adminUsers.js`:

- `POST /api/admin/users/:id/activate` — `users.delete` permission
  (same tier as deactivate; reverting deactivation is the same
  scope of action as performing it).
- `DELETE /api/admin/users/:id` — `users.delete`.

## Frontend

`UserManagementPage.tsx`:

- New mutation hooks: `activateUserMutation`, `deleteUserMutation`.
- The row's action cell now branches on `user.isActive`: active
  users see Edit + Deactivate (unchanged); deactivated users see
  Edit + Reactivate (`UserCheck` icon, green hover) + Delete
  (`Trash2` icon, red hover).
- The shared `ConfirmDialog` handles all four action types
  (deactivate / activate / delete / cancelInvitation) via per-type
  title / message / confirmText / variant lookup.

`userManagement.service.ts`:

- New `activateUser(id)` and `deleteUser(id)` methods mirroring the
  existing `deactivateUser` shape.

i18n keys are added with English fallbacks via `t(key, fallback)`
so the page works on every locale without a missing-translation
warning. Native translations can be filled in via a follow-up.

## Test plan

- [x] 8 new service tests pin: activate happy-path, idempotency on
  already-active, NotFoundError on missing target, activity log
  emitted, delete self-refusal, last-super-admin guard for both
  active and already-deactivated super_admin targets, hard-delete
  success, delete activity log.
- [x] Frontend type-check clean.
- [x] Frontend lint clean for the changed files.
- [x] Backend lint clean.
- [ ] Manual: deactivate a user → row now shows Reactivate + Delete
  → reactivate → user can log in again. Then deactivate again →
  delete → row vanishes, pending tokens for that user invalidated.

Closes the UX gap blazmaric called out in
https://github.com/the-luap/picpeak/pull/579#issuecomment-... .
This commit is contained in:
Paul Nothaft
2026-05-29 22:49:01 +02:00
parent d32bdda1b3
commit dfcebccee9
5 changed files with 396 additions and 17 deletions
+89 -17
View File
@@ -9,6 +9,7 @@ import {
Search,
Edit,
UserX,
UserCheck,
X,
AlertTriangle,
Clock,
@@ -379,7 +380,7 @@ export const UserManagementPage: React.FC = () => {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
type: 'deactivate' | 'cancelInvitation';
type: 'deactivate' | 'activate' | 'delete' | 'cancelInvitation';
id: number;
name: string;
} | null>(null);
@@ -463,6 +464,32 @@ export const UserManagementPage: React.FC = () => {
},
});
// #574 follow-up: reactivate + delete actions for the rows the
// deactivate button used to leave unmanageable.
const activateUserMutation = useMutation({
mutationFn: userManagementService.activateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userActivated', 'User reactivated successfully'));
},
onError: () => {
toast.error(t('userManagement.activateUserError', 'Failed to reactivate user'));
},
});
const deleteUserMutation = useMutation({
mutationFn: userManagementService.deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userDeleted', 'User deleted successfully'));
},
onError: () => {
toast.error(t('userManagement.deleteUserError', 'Failed to delete user'));
},
});
// Filtered data
const filteredUsers = useMemo(() => {
if (!users) return [];
@@ -512,6 +539,24 @@ export const UserManagementPage: React.FC = () => {
});
};
const handleActivateUser = (user: AdminUser) => {
setConfirmDialog({
isOpen: true,
type: 'activate',
id: user.id,
name: user.username,
});
};
const handleDeleteUser = (user: AdminUser) => {
setConfirmDialog({
isOpen: true,
type: 'delete',
id: user.id,
name: user.username,
});
};
const handleCancelInvitation = (invitation: AdminInvitation) => {
setConfirmDialog({
isOpen: true,
@@ -526,6 +571,10 @@ export const UserManagementPage: React.FC = () => {
if (confirmDialog.type === 'deactivate') {
deactivateUserMutation.mutate(confirmDialog.id);
} else if (confirmDialog.type === 'activate') {
activateUserMutation.mutate(confirmDialog.id);
} else if (confirmDialog.type === 'delete') {
deleteUserMutation.mutate(confirmDialog.id);
} else if (confirmDialog.type === 'cancelInvitation') {
cancelInvitationMutation.mutate(confirmDialog.id);
}
@@ -801,7 +850,7 @@ export const UserManagementPage: React.FC = () => {
>
<Edit className="w-4 h-4" />
</button>
{user.isActive && (
{user.isActive ? (
<button
onClick={() => handleDeactivateUser(user)}
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
@@ -809,6 +858,23 @@ export const UserManagementPage: React.FC = () => {
>
<UserX className="w-4 h-4" />
</button>
) : (
<>
<button
onClick={() => handleActivateUser(user)}
className="p-1.5 text-neutral-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded-lg transition-colors"
title={t('userManagement.activateUser', 'Reactivate user')}
>
<UserCheck className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteUser(user)}
className="p-1.5 text-neutral-400 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title={t('userManagement.deleteUser', 'Delete user permanently')}
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</td>
@@ -944,28 +1010,34 @@ export const UserManagementPage: React.FC = () => {
onClose={() => setConfirmDialog(null)}
onConfirm={handleConfirmAction}
title={
confirmDialog.type === 'deactivate'
? t('userManagement.confirmDeactivate.title')
: t('userManagement.confirmCancelInvitation.title')
confirmDialog.type === 'deactivate' ? t('userManagement.confirmDeactivate.title')
: confirmDialog.type === 'activate' ? t('userManagement.confirmActivate.title', 'Reactivate user?')
: confirmDialog.type === 'delete' ? t('userManagement.confirmDelete.title', 'Delete user permanently?')
: t('userManagement.confirmCancelInvitation.title')
}
message={
confirmDialog.type === 'deactivate'
? t('userManagement.confirmDeactivate.message', { name: confirmDialog.name })
: t('userManagement.confirmCancelInvitation.message', {
email: confirmDialog.name,
})
confirmDialog.type === 'deactivate' ? t('userManagement.confirmDeactivate.message', { name: confirmDialog.name })
: confirmDialog.type === 'activate' ? t('userManagement.confirmActivate.message', 'Reactivate {{name}}? They will be able to log in again immediately.', { name: confirmDialog.name })
: confirmDialog.type === 'delete' ? t('userManagement.confirmDelete.message', 'Permanently delete {{name}}? This cannot be undone. Their pending invitations and API tokens will be removed; records they created elsewhere will be kept but de-attributed.', { name: confirmDialog.name })
: t('userManagement.confirmCancelInvitation.message', { email: confirmDialog.name })
}
confirmText={
confirmDialog.type === 'deactivate'
? t('userManagement.deactivate')
: t('userManagement.cancel')
confirmDialog.type === 'deactivate' ? t('userManagement.deactivate')
: confirmDialog.type === 'activate' ? t('userManagement.activate', 'Reactivate')
: confirmDialog.type === 'delete' ? t('userManagement.delete', 'Delete permanently')
: t('userManagement.cancel')
}
isLoading={
confirmDialog.type === 'deactivate'
? deactivateUserMutation.isPending
: cancelInvitationMutation.isPending
confirmDialog.type === 'deactivate' ? deactivateUserMutation.isPending
: confirmDialog.type === 'activate' ? activateUserMutation.isPending
: confirmDialog.type === 'delete' ? deleteUserMutation.isPending
: cancelInvitationMutation.isPending
}
variant={
confirmDialog.type === 'activate' ? 'warning'
: confirmDialog.type === 'deactivate' || confirmDialog.type === 'delete' ? 'danger'
: 'warning'
}
variant={confirmDialog.type === 'deactivate' ? 'danger' : 'warning'}
/>
)}
</div>
@@ -204,6 +204,24 @@ export const userManagementService = {
return response.data.message;
},
/**
* Re-activate a previously deactivated admin user. Backend mirrors
* the deactivate endpoint exactly — see #574 follow-up.
*/
async activateUser(id: number): Promise<string> {
const response = await api.post<DeactivateUserResponse>(`/admin/users/${id}/activate`);
return response.data.message;
},
/**
* Permanently delete an admin user. UI nudges admins to deactivate
* first; backend FK rules cascade or SET NULL as appropriate.
*/
async deleteUser(id: number): Promise<string> {
const response = await api.delete<DeactivateUserResponse>(`/admin/users/${id}`);
return response.data.message;
},
/**
* Reset an admin user's password
*/