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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user