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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user