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