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
@@ -193,6 +193,13 @@ export interface Group {
createdAt: string;
}
export interface GroupDeletionConflict {
error: string;
memberCount: number;
appCount: number;
roleCount: number;
}
export interface GroupDetail {
id: number;
name: string;
@@ -768,6 +775,13 @@ export type ListGroupsParams = {
q?: string;
};
export type DeleteGroupParams = {
/**
* Force deletion of a non-empty group (records an audit log entry).
*/
force?: boolean;
};
export type GetAdminStatsParams = {
/**
* Time range for trend stats. Use "custom" with from/to.
+30 -13
View File
@@ -33,6 +33,7 @@ import type {
CreateServiceBody,
CreateServiceOrderBody,
CreateUserBody,
DeleteGroupParams,
ErrorResponse,
ForgotPasswordBody,
ForgotPasswordResponse,
@@ -40,6 +41,7 @@ import type {
GetAdminAppOpensByUserParams,
GetAdminStatsParams,
Group,
GroupDeletionConflict,
GroupDetail,
HealthStatus,
HomeStats,
@@ -5094,35 +5096,48 @@ export const useUpdateGroup = <
/**
* @summary Delete group (admin)
*/
export const getDeleteGroupUrl = (id: number) => {
return `/api/groups/${id}`;
export const getDeleteGroupUrl = (id: number, params?: DeleteGroupParams) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/groups/${id}?${stringifiedParams}`
: `/api/groups/${id}`;
};
export const deleteGroup = async (
id: number,
params?: DeleteGroupParams,
options?: RequestInit,
): Promise<void> => {
return customFetch<void>(getDeleteGroupUrl(id), {
return customFetch<void>(getDeleteGroupUrl(id, params), {
...options,
method: "DELETE",
});
};
export const getDeleteGroupMutationOptions = <
TError = ErrorType<ErrorResponse>,
TError = ErrorType<ErrorResponse | GroupDeletionConflict>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
{ id: number; params?: DeleteGroupParams },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
{ id: number; params?: DeleteGroupParams },
TContext
> => {
const mutationKey = ["deleteGroup"];
@@ -5136,11 +5151,11 @@ export const getDeleteGroupMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteGroup>>,
{ id: number }
{ id: number; params?: DeleteGroupParams }
> = (props) => {
const { id } = props ?? {};
const { id, params } = props ?? {};
return deleteGroup(id, requestOptions);
return deleteGroup(id, params, requestOptions);
};
return { mutationFn, ...mutationOptions };
@@ -5150,26 +5165,28 @@ export type DeleteGroupMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteGroup>>
>;
export type DeleteGroupMutationError = ErrorType<ErrorResponse>;
export type DeleteGroupMutationError = ErrorType<
ErrorResponse | GroupDeletionConflict
>;
/**
* @summary Delete group (admin)
*/
export const useDeleteGroup = <
TError = ErrorType<ErrorResponse>,
TError = ErrorType<ErrorResponse | GroupDeletionConflict>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
{ id: number; params?: DeleteGroupParams },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
{ id: number; params?: DeleteGroupParams },
TContext
> => {
return useMutation(getDeleteGroupMutationOptions(options));
+2 -2
View File
@@ -1,4 +1,4 @@
export * from "./generated/api";
export * from "./generated/api.schemas";
export { setBaseUrl, setAuthTokenGetter } from "./custom-fetch";
export type { AuthTokenGetter } from "./custom-fetch";
export { setBaseUrl, setAuthTokenGetter, ApiError } from "./custom-fetch";
export type { AuthTokenGetter, ErrorType } from "./custom-fetch";