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