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.
This commit is contained in:
Riyadh
2026-04-22 11:05:34 +00:00
parent 361b6848a2
commit 14ce113e70
11 changed files with 252 additions and 24 deletions
+37
View File
@@ -9,6 +9,7 @@ import {
usersTable,
appsTable,
rolesTable,
auditLogsTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { emitAppsChangedToUsers } from "../lib/realtime";
@@ -448,8 +449,44 @@ router.delete("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
res.status(400).json({ error: "Cannot delete a system group" });
return;
}
const force = req.query.force === "true" || req.query.force === "1";
const memberIds = await getGroupMemberIds(id);
const [appCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupAppsTable)
.where(eq(groupAppsTable.groupId, id));
const [roleCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupRolesTable)
.where(eq(groupRolesTable.groupId, id));
const memberCount = memberIds.length;
const appCount = appCountRow?.count ?? 0;
const roleCount = roleCountRow?.count ?? 0;
const isNonEmpty = memberCount > 0 || appCount > 0 || roleCount > 0;
if (isNonEmpty && !force) {
res.status(409).json({
error: "Group is not empty",
memberCount,
appCount,
roleCount,
});
return;
}
await db.delete(groupsTable).where(eq(groupsTable.id, id));
if (isNonEmpty && force) {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "group.force_delete",
targetType: "group",
targetId: id,
metadata: {
name: existing.name,
memberCount,
appCount,
roleCount,
},
});
}
await emitAppsChangedToUsers(memberIds);
res.sendStatus(204);
});