#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-... .
249 lines
7.5 KiB
TypeScript
249 lines
7.5 KiB
TypeScript
import { api } from '../config/api';
|
|
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
|
|
|
|
/**
|
|
* Defence-in-depth normalisation for date fields (#485). The backend
|
|
* now always returns these as ISO strings via toIso() in adminUsers.js,
|
|
* but this wrapper:
|
|
*
|
|
* - Lets the page survive a stale backend during a mid-deploy window
|
|
* (older backend still returns SQLite epoch-ms numbers).
|
|
* - Lets the page survive an external API consumer's response cache
|
|
* that captured the pre-fix shape.
|
|
*
|
|
* The original crash was `parseISO(123456789)` in
|
|
* AdminUsersPage → `e.split is not a function`. Coercing here means
|
|
* the next consumer of the AdminUser type can rely on the field
|
|
* being a string regardless of how it landed.
|
|
*/
|
|
function normalizeDateValue(value: unknown): string | null | undefined {
|
|
if (value === null || value === undefined || value === '') return value as null | undefined;
|
|
if (value instanceof Date) return value.toISOString();
|
|
if (typeof value === 'number') return new Date(value).toISOString();
|
|
if (typeof value === 'string') {
|
|
// SQLite via some drivers stringifies large integers — coerce back.
|
|
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Transform snake_case API response to camelCase for frontend
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function transformUser(user: any): AdminUser {
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
isActive: user.isActive ?? user.is_active,
|
|
lastLogin: normalizeDateValue(user.lastLogin ?? user.last_login),
|
|
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
|
|
createdAt: normalizeDateValue(user.createdAt ?? user.created_at),
|
|
updatedAt: normalizeDateValue(user.updatedAt ?? user.updated_at),
|
|
roleId: user.roleId ?? user.role_id,
|
|
roleName: user.roleName ?? user.role_name,
|
|
roleDisplayName: user.roleDisplayName ?? user.role_display_name,
|
|
createdByUsername: user.createdByUsername ?? user.created_by_username,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function transformInvitation(invitation: any): AdminInvitation {
|
|
return {
|
|
id: invitation.id,
|
|
email: invitation.email,
|
|
roleName: invitation.roleName ?? invitation.role_name,
|
|
invitedBy: invitation.invitedBy ?? invitation.invited_by,
|
|
expiresAt: normalizeDateValue(invitation.expiresAt ?? invitation.expires_at) as string,
|
|
createdAt: normalizeDateValue(invitation.createdAt ?? invitation.created_at) as string,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function transformRole(role: any): AdminRole {
|
|
return {
|
|
id: role.id,
|
|
name: role.name,
|
|
displayName: role.displayName ?? role.display_name,
|
|
description: role.description,
|
|
isSystem: role.isSystem ?? role.is_system,
|
|
priority: role.priority,
|
|
};
|
|
}
|
|
|
|
interface GetUsersResponse {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
users: any[];
|
|
}
|
|
|
|
interface GetUserResponse {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
user: any;
|
|
}
|
|
|
|
interface GetRolesResponse {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
roles: any[];
|
|
}
|
|
|
|
interface GetInvitationsResponse {
|
|
invitations: AdminInvitation[];
|
|
}
|
|
|
|
interface CreateInvitationData {
|
|
email: string;
|
|
role_id: number;
|
|
}
|
|
|
|
interface CreateInvitationResponse {
|
|
invitation: AdminInvitation;
|
|
message: string;
|
|
}
|
|
|
|
interface UpdateUserData {
|
|
roleId?: number;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
interface UpdateUserResponse {
|
|
user: AdminUser;
|
|
}
|
|
|
|
interface DeactivateUserResponse {
|
|
message: string;
|
|
}
|
|
|
|
interface ResetPasswordResponse {
|
|
temporaryPassword: string;
|
|
message: string;
|
|
}
|
|
|
|
interface ValidateInvitationResponse {
|
|
valid: boolean;
|
|
email: string;
|
|
roleName: string;
|
|
invitedBy: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
interface AcceptInvitationData {
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
interface AcceptInvitationResponse {
|
|
message: string;
|
|
user: AdminUser;
|
|
}
|
|
|
|
export const userManagementService = {
|
|
/**
|
|
* Get all admin users
|
|
*/
|
|
async getUsers(): Promise<AdminUser[]> {
|
|
const response = await api.get<GetUsersResponse>('/admin/users');
|
|
return response.data.users.map(transformUser);
|
|
},
|
|
|
|
/**
|
|
* Get a single admin user by ID
|
|
*/
|
|
async getUser(id: number): Promise<AdminUser> {
|
|
const response = await api.get<GetUserResponse>(`/admin/users/${id}`);
|
|
return transformUser(response.data.user);
|
|
},
|
|
|
|
/**
|
|
* Get all available roles
|
|
*/
|
|
async getRoles(): Promise<AdminRole[]> {
|
|
const response = await api.get<GetRolesResponse>('/admin/users/roles');
|
|
return response.data.roles.map(transformRole);
|
|
},
|
|
|
|
/**
|
|
* Get all pending invitations
|
|
*/
|
|
async getInvitations(): Promise<AdminInvitation[]> {
|
|
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
|
|
return response.data.invitations.map(transformInvitation);
|
|
},
|
|
|
|
/**
|
|
* Create a new invitation
|
|
*/
|
|
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
|
|
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
|
|
return transformInvitation(response.data.invitation);
|
|
},
|
|
|
|
/**
|
|
* Cancel a pending invitation
|
|
*/
|
|
async cancelInvitation(id: number): Promise<void> {
|
|
await api.delete(`/admin/users/invitations/${id}`);
|
|
},
|
|
|
|
/**
|
|
* Update an admin user
|
|
*/
|
|
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
|
|
// Convert camelCase to snake_case for backend API
|
|
const payload: Record<string, unknown> = {};
|
|
if (data.roleId !== undefined) payload.role_id = data.roleId;
|
|
if (data.isActive !== undefined) payload.is_active = data.isActive;
|
|
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, payload);
|
|
return transformUser(response.data.user);
|
|
},
|
|
|
|
/**
|
|
* Deactivate an admin user
|
|
*/
|
|
async deactivateUser(id: number): Promise<string> {
|
|
const response = await api.post<DeactivateUserResponse>(`/admin/users/${id}/deactivate`);
|
|
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
|
|
*/
|
|
async resetPassword(id: number): Promise<ResetPasswordResponse> {
|
|
const response = await api.post<ResetPasswordResponse>(`/admin/users/${id}/reset-password`);
|
|
return response.data;
|
|
},
|
|
|
|
/**
|
|
* Validate an invitation token (public endpoint)
|
|
*/
|
|
async validateInvitation(token: string): Promise<ValidateInvitationResponse> {
|
|
const response = await api.get<ValidateInvitationResponse>(`/invite/${token}`);
|
|
return response.data;
|
|
},
|
|
|
|
/**
|
|
* Accept an invitation and create account (public endpoint)
|
|
*/
|
|
async acceptInvitation(token: string, data: AcceptInvitationData): Promise<AcceptInvitationResponse> {
|
|
const response = await api.post<AcceptInvitationResponse>(`/invite/${token}`, data);
|
|
return response.data;
|
|
},
|
|
};
|