Warn admins before deleting non-empty groups

Original task: Task #79 — Make DELETE /api/groups/:id refuse to wipe a non-empty
group unless explicitly forced, surface the impacted counts in the admin UI,
and log forced deletions to an audit trail.

Changes:
- API: DELETE /api/groups/:id now computes member/app/role counts. If any are
  non-zero and `?force=true` is not passed, it returns 409 with
  { error, memberCount, appCount, roleCount }. With `force=true` it deletes
  and writes an entry to the new `audit_logs` table
  (action='group.force_delete', target_type='group', target_id, metadata).
  System group guard and 404 behaviour preserved.
- DB: Added `audit_logs` table (lib/db/src/schema/audit-logs.ts) with actor,
  action, target type/id, jsonb metadata, timestamp. Pushed via drizzle-kit.
- OpenAPI: Added `force` query param, 409 response, and `GroupDeletionConflict`
  schema. Re-ran orval codegen for api-client-react and api-zod.
- api-client-react: Re-exported `ApiError` and `ErrorType` so the UI can
  inspect 409 responses.
- Admin UI (artifacts/teaboy-os/src/pages/admin.tsx GroupsPanel):
  Replaced the bare `confirm()` with a confirmation dialog that shows the
  group name and impacted member/app/role counts. Empty groups get a simple
  "Delete" confirmation; non-empty groups get a warning + "Delete anyway"
  button which sends `force=true`. If the server returns 409 (race), the
  dialog updates to show the server-reported counts.
- Locales: Added en/ar strings for the new dialog (deleteTitle, deleteWarning,
  deleteForceHint, deleteEmptyBody, deleteConfirm, deleteAnyway).

Verified end-to-end: 409 on first DELETE, dialog warning visible, force
deletion succeeds, single audit_logs row recorded with correct metadata.

Replit-Task-Id: 61624ce4-83b3-43be-b22b-e261662301f1
This commit is contained in:
riyadhafraa
2026-04-22 11:05:34 +00:00
parent 959ad9ca51
commit cd0a8313e0
11 changed files with 252 additions and 24 deletions
+101 -9
View File
@@ -40,8 +40,10 @@ import {
useDeleteGroup,
useListRoles,
getListRolesQueryKey,
ApiError,
} from "@workspace/api-client-react";
import type {
GroupDeletionConflict,
App,
Service,
UserProfile,
@@ -2417,9 +2419,45 @@ function GroupsPanel() {
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const [groupSearch, setGroupSearch] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Group | null>(null);
const [conflictCounts, setConflictCounts] = useState<{
memberCount: number;
appCount: number;
roleCount: number;
} | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListGroupsQueryKey() });
const closeDeleteDialog = () => {
setDeleteTarget(null);
setConflictCounts(null);
};
const performDelete = (group: Group, force: boolean) => {
deleteGroup.mutate(
{ id: group.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
invalidate();
toast({ title: t("common.success") });
closeDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
const data = err.data as GroupDeletionConflict;
setConflictCounts({
memberCount: data.memberCount,
appCount: data.appCount,
roleCount: data.roleCount,
});
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const visibleGroups = (groups ?? []).filter((g) =>
!groupSearch.trim() ||
g.name.toLowerCase().includes(groupSearch.trim().toLowerCase()) ||
@@ -2474,17 +2512,11 @@ function GroupsPanel() {
{!g.isSystem && (
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteGroup.mutate(
{ id: g.id },
{
onSuccess: () => { invalidate(); toast({ title: t("common.success") }); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}
setConflictCounts(null);
setDeleteTarget(g);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`delete-group-${g.id}`}
>
<Trash2 size={14} />
</button>
@@ -2557,6 +2589,66 @@ function GroupsPanel() {
}}
/>
)}
{deleteTarget && (() => {
const counts = conflictCounts ?? {
memberCount: deleteTarget.memberCount,
appCount: deleteTarget.appCount,
roleCount: deleteTarget.roleCount,
};
const isNonEmpty =
counts.memberCount > 0 || counts.appCount > 0 || counts.roleCount > 0;
const showWarning = conflictCounts !== null || isNonEmpty;
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="delete-group-dialog">
<h2 className="font-semibold text-foreground">
{t("admin.groups.deleteTitle", { name: deleteTarget.name })}
</h2>
{showWarning ? (
<>
<p className="text-sm text-destructive">
{t("admin.groups.deleteWarning")}
</p>
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
<li>{t("admin.groups.members", { count: counts.memberCount })}</li>
<li>{t("admin.groups.appsCount", { count: counts.appCount })}</li>
<li>{t("admin.groups.rolesCount", { count: counts.roleCount })}</li>
</ul>
<p className="text-xs text-muted-foreground">
{t("admin.groups.deleteForceHint")}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t("admin.groups.deleteEmptyBody")}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={closeDeleteDialog}
disabled={deleteGroup.isPending}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
variant="destructive"
disabled={deleteGroup.isPending}
onClick={() => performDelete(deleteTarget, showWarning)}
data-testid="confirm-delete-group"
>
{showWarning
? t("admin.groups.deleteAnyway")
: t("admin.groups.deleteConfirm")}
</Button>
</div>
</div>
</div>
);
})()}
</div>
);
}