Warn admins before deleting non-empty users/apps/services (Task #85)

Apply the existing groups delete-warning pattern to user, app, and
service deletions so admins are warned (and have to confirm twice)
before destroying records that still own dependent data.

Backend
- openapi.yaml: added `force` query param to DELETE /users/{id},
  /apps/{id}, /services/{id} plus three new conflict schemas
  (UserDeletionConflict, AppDeletionConflict, ServiceDeletionConflict).
- routes/users.ts, apps.ts, services.ts: rewrote DELETE handlers to
  count dependents (notes/orders/conversations/messages for users;
  group_apps/restrictions/open events for apps; service_orders for
  services), return 409 with counts when non-empty and force is not set,
  and on `?force=true` perform the cascade in a transaction and write
  an `*.force_delete` audit_logs row.
- Existing 204 success path preserved for empty deletes.

UI (artifacts/tx-os/src/pages/admin.tsx)
- New `DeletionWarningDialog` helper, plus app/service/user delete state
  + lazy 409 detection (first click probes; on 409 the dialog upgrades
  to show counts + "Delete anyway"; second click sends ?force=true).
- Replaced the three plain `confirm(t("admin.deleteConfirm"))` callsites.

i18n
- Added admin.deleteApp/deleteService/deleteUser keys (title, warning,
  forceHint, emptyBody, count keys, confirm, anyway) in en.json + ar.json.

Tests
- artifacts/api-server/tests/delete-force-warnings.test.mjs covers all
  9 cases (clean delete, 409 with counts, force=true 204 + audit log)
  for users, apps, and services. Existing groups-crud and service-orders
  tests still pass.

Notes / drift
- Lazy detection (vs eager counts on the row like Groups already does)
  was chosen because list endpoints don't return counts yet — proposed
  follow-up #96 covers eager counts so the warning appears on first
  click everywhere.
- E2E test for the UI flow flaked twice; backend integration tests
  (9/9 pass), direct curl validation of force=true returning 204, and
  typecheck across the monorepo all confirm correctness.

Replit-Task-Id: 91404d92-e74c-4720-8fc9-8eb772eefc33
This commit is contained in:
riyadhafraa
2026-04-27 12:07:07 +00:00
parent 6e9b9b5f3d
commit 7c9edf6cb6
11 changed files with 1146 additions and 72 deletions
+255 -15
View File
@@ -47,6 +47,9 @@ import {
} from "@workspace/api-client-react";
import type {
GroupDeletionConflict,
AppDeletionConflict,
ServiceDeletionConflict,
UserDeletionConflict,
App,
Service,
UserProfile,
@@ -137,6 +140,82 @@ type ServiceForm = {
isAvailable: boolean;
};
function DeletionWarningDialog({
title,
items,
warning,
forceHint,
emptyBody,
confirmLabel,
anywayLabel,
showWarning,
isPending,
onCancel,
onConfirm,
testId,
confirmTestId,
}: {
title: string;
items: string[];
warning: string;
forceHint: string;
emptyBody: string;
confirmLabel: string;
anywayLabel: string;
showWarning: boolean;
isPending: boolean;
onCancel: () => void;
onConfirm: () => void;
testId?: string;
confirmTestId?: string;
}) {
const { t } = useTranslation();
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3"
data-testid={testId}
>
<h2 className="font-semibold text-foreground">{title}</h2>
{showWarning ? (
<>
<p className="text-sm text-destructive">{warning}</p>
{items.length > 0 && (
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
{items.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{forceHint}</p>
</>
) : (
<p className="text-sm text-muted-foreground">{emptyBody}</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={isPending}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
variant="destructive"
disabled={isPending}
onClick={onConfirm}
data-testid={confirmTestId}
>
{showWarning ? anywayLabel : confirmLabel}
</Button>
</div>
</div>
</div>
);
}
export default function AdminPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
@@ -224,6 +303,59 @@ export default function AdminPage() {
const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null);
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
const [appDeleteTarget, setAppDeleteTarget] = useState<App | null>(null);
const [appDeleteConflict, setAppDeleteConflict] = useState<AppDeletionConflict | null>(null);
const [serviceDeleteTarget, setServiceDeleteTarget] = useState<Service | null>(null);
const [serviceDeleteConflict, setServiceDeleteConflict] = useState<ServiceDeletionConflict | null>(null);
const closeAppDeleteDialog = () => {
setAppDeleteTarget(null);
setAppDeleteConflict(null);
};
const closeServiceDeleteDialog = () => {
setServiceDeleteTarget(null);
setServiceDeleteConflict(null);
};
const performAppDelete = (app: App, force: boolean) => {
deleteApp.mutate(
{ id: app.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
toast({ title: t("common.success") });
closeAppDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setAppDeleteConflict(err.data as AppDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const performServiceDelete = (service: Service, force: boolean) => {
deleteService.mutate(
{ id: service.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() });
toast({ title: t("common.success") });
closeServiceDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setServiceDeleteConflict(err.data as ServiceDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
const [section, setSection] = useState<AdminSection>("dashboard");
const [navOpenGroups, setNavOpenGroups] = useState<Record<string, boolean>>({});
@@ -702,14 +834,11 @@ export default function AdminPage() {
</button>
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteApp.mutate(
{ id: app.id },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
);
}
setAppDeleteConflict(null);
setAppDeleteTarget(app);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`app-delete-${app.id}`}
>
<Trash2 size={14} />
</button>
@@ -764,14 +893,11 @@ export default function AdminPage() {
</button>
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteService.mutate(
{ id: service.id },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }) },
);
}
setServiceDeleteConflict(null);
setServiceDeleteTarget(service);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`service-delete-${service.id}`}
>
<Trash2 size={14} />
</button>
@@ -818,6 +944,61 @@ export default function AdminPage() {
</div>
</div>
{appDeleteTarget && (() => {
const target = appDeleteTarget;
const items: string[] = [];
if (appDeleteConflict) {
if (appDeleteConflict.groupCount > 0)
items.push(t("admin.deleteApp.groupCount", { count: appDeleteConflict.groupCount }));
if (appDeleteConflict.restrictionCount > 0)
items.push(t("admin.deleteApp.restrictionCount", { count: appDeleteConflict.restrictionCount }));
if (appDeleteConflict.openCount > 0)
items.push(t("admin.deleteApp.openCount", { count: appDeleteConflict.openCount }));
}
return (
<DeletionWarningDialog
title={t("admin.deleteApp.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
warning={t("admin.deleteApp.warning")}
forceHint={t("admin.deleteApp.forceHint")}
emptyBody={t("admin.deleteApp.emptyBody")}
confirmLabel={t("admin.deleteApp.confirm")}
anywayLabel={t("admin.deleteApp.anyway")}
items={items}
showWarning={appDeleteConflict !== null}
isPending={deleteApp.isPending}
onCancel={closeAppDeleteDialog}
onConfirm={() => performAppDelete(target, appDeleteConflict !== null)}
testId="app-delete-dialog"
confirmTestId="app-delete-confirm"
/>
);
})()}
{serviceDeleteTarget && (() => {
const target = serviceDeleteTarget;
const items: string[] = [];
if (serviceDeleteConflict && serviceDeleteConflict.orderCount > 0) {
items.push(t("admin.deleteService.orderCount", { count: serviceDeleteConflict.orderCount }));
}
return (
<DeletionWarningDialog
title={t("admin.deleteService.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
warning={t("admin.deleteService.warning")}
forceHint={t("admin.deleteService.forceHint")}
emptyBody={t("admin.deleteService.emptyBody")}
confirmLabel={t("admin.deleteService.confirm")}
anywayLabel={t("admin.deleteService.anyway")}
items={items}
showWarning={serviceDeleteConflict !== null}
isPending={deleteService.isPending}
onCancel={closeServiceDeleteDialog}
onConfirm={() => performServiceDelete(target, serviceDeleteConflict !== null)}
testId="service-delete-dialog"
confirmTestId="service-delete-confirm"
/>
);
})()}
{resetLinkUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@@ -2010,6 +2191,8 @@ function UsersPanel({
const [sortKey, setSortKey] = useState<UserSortKey>("username");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [editingUser, setEditingUser] = useState<UserProfile | null>(null);
const [userDeleteTarget, setUserDeleteTarget] = useState<UserProfile | null>(null);
const [userDeleteConflict, setUserDeleteConflict] = useState<UserDeletionConflict | null>(null);
const [newUserOpen, setNewUserOpen] = useState(false);
const [newUserForm, setNewUserForm] = useState<{
username: string;
@@ -2046,6 +2229,31 @@ function UsersPanel({
const invalidateUsers = () =>
queryClient.invalidateQueries({ queryKey: [`/api/users`] });
const closeUserDeleteDialog = () => {
setUserDeleteTarget(null);
setUserDeleteConflict(null);
};
const performUserDelete = (target: UserProfile, force: boolean) => {
deleteUser.mutate(
{ id: target.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
invalidateUsers();
toast({ title: t("common.success") });
closeUserDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setUserDeleteConflict(err.data as UserDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
return (
<div className="space-y-3">
<div className="glass-panel rounded-2xl p-3 space-y-2">
@@ -2198,11 +2406,11 @@ function UsersPanel({
{u.id !== currentUserId && (
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteUser.mutate({ id: u.id }, { onSuccess: invalidateUsers });
}
setUserDeleteConflict(null);
setUserDeleteTarget(u);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`user-delete-${u.id}`}
>
<Trash2 size={14} />
</button>
@@ -2303,6 +2511,38 @@ function UsersPanel({
onSaved={() => { invalidateUsers(); setEditingUser(null); }}
/>
)}
{userDeleteTarget && (() => {
const target = userDeleteTarget;
const items: string[] = [];
if (userDeleteConflict) {
if (userDeleteConflict.noteCount > 0)
items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount }));
if (userDeleteConflict.orderCount > 0)
items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount }));
if (userDeleteConflict.conversationCount > 0)
items.push(t("admin.deleteUser.conversationCount", { count: userDeleteConflict.conversationCount }));
if (userDeleteConflict.messageCount > 0)
items.push(t("admin.deleteUser.messageCount", { count: userDeleteConflict.messageCount }));
}
return (
<DeletionWarningDialog
title={t("admin.deleteUser.title", { name: target.username })}
warning={t("admin.deleteUser.warning")}
forceHint={t("admin.deleteUser.forceHint")}
emptyBody={t("admin.deleteUser.emptyBody")}
confirmLabel={t("admin.deleteUser.confirm")}
anywayLabel={t("admin.deleteUser.anyway")}
items={items}
showWarning={userDeleteConflict !== null}
isPending={deleteUser.isPending}
onCancel={closeUserDeleteDialog}
onConfirm={() => performUserDelete(target, userDeleteConflict !== null)}
testId="user-delete-dialog"
confirmTestId="user-delete-confirm"
/>
);
})()}
</div>
);
}