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