Warn admins before deleting non-empty users/apps/services (Task #85)
Apply the existing groups delete-warning pattern to user, app, and
service deletions so admins are warned (and have to confirm twice)
before destroying records that still own dependent data.
Backend
- openapi.yaml: added `force` query param to DELETE /users/{id},
/apps/{id}, /services/{id} plus three new conflict schemas
(UserDeletionConflict, AppDeletionConflict, ServiceDeletionConflict).
- routes/users.ts, apps.ts, services.ts: rewrote DELETE handlers to
count dependents (notes/orders/conversations/messages for users;
group_apps/restrictions/open events for apps; service_orders for
services), return 409 with counts when non-empty and force is not set,
and on `?force=true` perform the cascade in a transaction and write
an `*.force_delete` audit_logs row.
- Existing 204 success path preserved for empty deletes.
UI (artifacts/tx-os/src/pages/admin.tsx)
- New `DeletionWarningDialog` helper, plus app/service/user delete state
+ lazy 409 detection (first click probes; on 409 the dialog upgrades
to show counts + "Delete anyway"; second click sends ?force=true).
- Replaced the three plain `confirm(t("admin.deleteConfirm"))` callsites.
i18n
- Added admin.deleteApp/deleteService/deleteUser keys (title, warning,
forceHint, emptyBody, count keys, confirm, anyway) in en.json + ar.json.
Tests
- artifacts/api-server/tests/delete-force-warnings.test.mjs covers all
9 cases (clean delete, 409 with counts, force=true 204 + audit log)
for users, apps, and services. Existing groups-crud and service-orders
tests still pass.
Notes / drift
- Lazy detection (vs eager counts on the row like Groups already does)
was chosen because list endpoints don't return counts yet — proposed
follow-up #96 covers eager counts so the warning appears on first
click everywhere.
- E2E test for the UI flow flaked twice; backend integration tests
(9/9 pass), direct curl validation of force=true returning 204, and
typecheck across the monorepo all confirm correctness.
Replit-Task-Id: 91404d92-e74c-4720-8fc9-8eb772eefc33
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
appOpensTable,
|
||||
userGroupsTable,
|
||||
groupAppsTable,
|
||||
auditLogsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
|
||||
import {
|
||||
@@ -253,16 +254,65 @@ router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [app] = await db
|
||||
.delete(appsTable)
|
||||
.where(eq(appsTable.id, params.data.id))
|
||||
.returning();
|
||||
const appId = params.data.id;
|
||||
|
||||
if (!app) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.id, appId));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const force = req.query.force === "true" || req.query.force === "1";
|
||||
|
||||
const [groupRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(groupAppsTable)
|
||||
.where(eq(groupAppsTable.appId, appId));
|
||||
const [restrictionRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appPermissionsTable)
|
||||
.where(eq(appPermissionsTable.appId, appId));
|
||||
const [openRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appOpensTable)
|
||||
.where(eq(appOpensTable.appId, appId));
|
||||
|
||||
const groupCount = groupRow?.count ?? 0;
|
||||
const restrictionCount = restrictionRow?.count ?? 0;
|
||||
const openCount = openRow?.count ?? 0;
|
||||
const hasDeps = groupCount > 0 || restrictionCount > 0 || openCount > 0;
|
||||
|
||||
if (hasDeps && !force) {
|
||||
res.status(409).json({
|
||||
error: "App has dependent records",
|
||||
groupCount,
|
||||
restrictionCount,
|
||||
openCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.delete(appsTable).where(eq(appsTable.id, appId));
|
||||
|
||||
if (hasDeps && force) {
|
||||
await db.insert(auditLogsTable).values({
|
||||
actorUserId: req.session.userId ?? null,
|
||||
action: "app.force_delete",
|
||||
targetType: "app",
|
||||
targetId: appId,
|
||||
metadata: {
|
||||
slug: existing.slug,
|
||||
nameEn: existing.nameEn,
|
||||
groupCount,
|
||||
restrictionCount,
|
||||
openCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { eq, asc, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import { servicesTable, serviceCategoriesTable } from "@workspace/db";
|
||||
import {
|
||||
servicesTable,
|
||||
serviceCategoriesTable,
|
||||
serviceOrdersTable,
|
||||
auditLogsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||
import {
|
||||
CreateServiceBody,
|
||||
@@ -94,16 +99,59 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
const [service] = await db
|
||||
.delete(servicesTable)
|
||||
.where(eq(servicesTable.id, params.data.id))
|
||||
.returning();
|
||||
const serviceId = params.data.id;
|
||||
|
||||
if (!service) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(servicesTable)
|
||||
.where(eq(servicesTable.id, serviceId));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Service not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const force = req.query.force === "true" || req.query.force === "1";
|
||||
|
||||
const [orderRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.serviceId, serviceId));
|
||||
|
||||
const orderCount = orderRow?.count ?? 0;
|
||||
const hasDeps = orderCount > 0;
|
||||
|
||||
if (hasDeps && !force) {
|
||||
res.status(409).json({
|
||||
error: "Service has dependent records",
|
||||
orderCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (hasDeps) {
|
||||
// service_orders.service_id has ON DELETE RESTRICT, so we must clear
|
||||
// dependent orders first before the service itself can be removed.
|
||||
await tx
|
||||
.delete(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.serviceId, serviceId));
|
||||
}
|
||||
await tx.delete(servicesTable).where(eq(servicesTable.id, serviceId));
|
||||
});
|
||||
|
||||
if (hasDeps && force) {
|
||||
await db.insert(auditLogsTable).values({
|
||||
actorUserId: req.session.userId ?? null,
|
||||
action: "service.force_delete",
|
||||
targetType: "service",
|
||||
targetId: serviceId,
|
||||
metadata: {
|
||||
nameEn: existing.nameEn,
|
||||
orderCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and, or, ilike, inArray, asc, type SQL } from "drizzle-orm";
|
||||
import { eq, and, or, ilike, inArray, asc, sql, type SQL } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
usersTable,
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
rolesTable,
|
||||
groupsTable,
|
||||
userGroupsTable,
|
||||
notesTable,
|
||||
serviceOrdersTable,
|
||||
conversationsTable,
|
||||
messagesTable,
|
||||
auditLogsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
import { emitAppsChangedToUsers } from "../lib/realtime";
|
||||
@@ -457,16 +462,91 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.delete(usersTable)
|
||||
.where(eq(usersTable.id, params.data.id))
|
||||
.returning();
|
||||
const userId = params.data.id;
|
||||
|
||||
if (!user) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const force = req.query.force === "true" || req.query.force === "1";
|
||||
|
||||
const [noteRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(notesTable)
|
||||
.where(eq(notesTable.userId, userId));
|
||||
const [orderRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.userId, userId));
|
||||
const [convRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, userId));
|
||||
const [msgRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(messagesTable)
|
||||
.where(eq(messagesTable.senderId, userId));
|
||||
|
||||
const noteCount = noteRow?.count ?? 0;
|
||||
const orderCount = orderRow?.count ?? 0;
|
||||
const conversationCount = convRow?.count ?? 0;
|
||||
const messageCount = msgRow?.count ?? 0;
|
||||
const hasDeps =
|
||||
noteCount > 0 ||
|
||||
orderCount > 0 ||
|
||||
conversationCount > 0 ||
|
||||
messageCount > 0;
|
||||
|
||||
if (hasDeps && !force) {
|
||||
res.status(409).json({
|
||||
error: "User has dependent records",
|
||||
noteCount,
|
||||
orderCount,
|
||||
conversationCount,
|
||||
messageCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (force) {
|
||||
// Hard cleanup of records that don't cascade automatically so the
|
||||
// user delete can succeed without FK violations.
|
||||
if (conversationCount > 0) {
|
||||
await tx
|
||||
.delete(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, userId));
|
||||
}
|
||||
if (messageCount > 0) {
|
||||
await tx
|
||||
.delete(messagesTable)
|
||||
.where(eq(messagesTable.senderId, userId));
|
||||
}
|
||||
}
|
||||
await tx.delete(usersTable).where(eq(usersTable.id, userId));
|
||||
});
|
||||
|
||||
if (hasDeps && force) {
|
||||
await db.insert(auditLogsTable).values({
|
||||
actorUserId: req.session.userId ?? null,
|
||||
action: "user.force_delete",
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
metadata: {
|
||||
username: existing.username,
|
||||
noteCount,
|
||||
orderCount,
|
||||
conversationCount,
|
||||
messageCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
let adminId;
|
||||
let adminUsername;
|
||||
let adminCookie;
|
||||
let createdUserIds = [];
|
||||
let createdAppIds = [];
|
||||
let createdServiceIds = [];
|
||||
let createdGroupIds = [];
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
return setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
adminUsername = `del_admin_${stamp}`;
|
||||
|
||||
const a = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Delete Admin', 'en', true) RETURNING id`,
|
||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
adminId = a.rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||
[adminId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId],
|
||||
);
|
||||
|
||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdServiceIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM service_orders WHERE service_id = ANY($1::int[])`,
|
||||
[createdServiceIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM services WHERE id = ANY($1::int[])`, [
|
||||
createdServiceIds,
|
||||
]);
|
||||
}
|
||||
if (createdAppIds.length) {
|
||||
await pool.query(`DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [
|
||||
createdAppIds,
|
||||
]);
|
||||
await pool.query(
|
||||
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [
|
||||
createdAppIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
|
||||
createdAppIds,
|
||||
]);
|
||||
}
|
||||
if (createdGroupIds.length) {
|
||||
await pool.query(`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`, [
|
||||
createdGroupIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
|
||||
createdGroupIds,
|
||||
]);
|
||||
}
|
||||
for (const uid of [adminId, ...createdUserIds]) {
|
||||
if (uid !== undefined) {
|
||||
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
|
||||
uid,
|
||||
]);
|
||||
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
|
||||
}
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
// ---------- Users ----------
|
||||
|
||||
test("DELETE /api/users/:id without dependents succeeds (no force needed)", async () => {
|
||||
const username = `del_clean_${Date.now().toString(36)}`;
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Clean User', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const uid = r.rows[0].id;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const check = await pool.query(`SELECT id FROM users WHERE id = $1`, [uid]);
|
||||
assert.equal(check.rowCount, 0);
|
||||
});
|
||||
|
||||
test("DELETE /api/users/:id with dependents returns 409 with counts", async () => {
|
||||
const username = `del_busy_${Date.now().toString(36)}`;
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Busy User', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const uid = r.rows[0].id;
|
||||
createdUserIds.push(uid);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'Note A', 'x'), ($1, 'Note B', 'y')`,
|
||||
[uid],
|
||||
);
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Test Conv', true) RETURNING id`,
|
||||
[uid],
|
||||
);
|
||||
const convId = conv.rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hello')`,
|
||||
[convId, uid],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 409);
|
||||
const body = await res.json();
|
||||
assert.equal(body.noteCount, 2);
|
||||
assert.equal(body.conversationCount, 1);
|
||||
assert.equal(body.messageCount, 1);
|
||||
|
||||
const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
|
||||
uid,
|
||||
]);
|
||||
assert.equal(stillThere.rowCount, 1);
|
||||
});
|
||||
|
||||
test("DELETE /api/users/:id?force=true deletes user and writes audit log", async () => {
|
||||
const username = `del_force_${Date.now().toString(36)}`;
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Force User', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const uid = r.rows[0].id;
|
||||
|
||||
await pool.query(`INSERT INTO notes (user_id, title) VALUES ($1, 'n')`, [
|
||||
uid,
|
||||
]);
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Conv', true) RETURNING id`,
|
||||
[uid],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[conv.rows[0].id, uid],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${uid}?force=true`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const check = await pool.query(`SELECT id FROM users WHERE id = $1`, [uid]);
|
||||
assert.equal(check.rowCount, 0);
|
||||
|
||||
const audit = await pool.query(
|
||||
`SELECT * FROM audit_logs WHERE action = 'user.force_delete' AND target_id = $1`,
|
||||
[uid],
|
||||
);
|
||||
assert.equal(audit.rowCount, 1);
|
||||
assert.equal(audit.rows[0].actor_user_id, adminId);
|
||||
});
|
||||
|
||||
// ---------- Apps ----------
|
||||
|
||||
test("DELETE /api/apps/:id without dependents succeeds (no force needed)", async () => {
|
||||
const slug = `clean_app_${Date.now().toString(36)}`;
|
||||
const a = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
|
||||
VALUES ($1, 'تطبيق', 'App Clean', '/x', 999) RETURNING id`,
|
||||
[slug],
|
||||
);
|
||||
const id = a.rows[0].id;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/apps/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
});
|
||||
|
||||
test("DELETE /api/apps/:id with dependents returns 409 with counts", async () => {
|
||||
const slug = `busy_app_${Date.now().toString(36)}`;
|
||||
const a = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
|
||||
VALUES ($1, 'تطبيق', 'App Busy', '/y', 999) RETURNING id`,
|
||||
[slug],
|
||||
);
|
||||
const appId = a.rows[0].id;
|
||||
createdAppIds.push(appId);
|
||||
|
||||
const g = await pool.query(
|
||||
`INSERT INTO groups (name, description_en) VALUES ($1, 'g') RETURNING id`,
|
||||
[`gApp_${Date.now().toString(36)}`],
|
||||
);
|
||||
const groupId = g.rows[0].id;
|
||||
createdGroupIds.push(groupId);
|
||||
await pool.query(
|
||||
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
||||
[groupId, appId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2), ($1, $2)`,
|
||||
[adminId, appId],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 409);
|
||||
const body = await res.json();
|
||||
assert.equal(body.groupCount, 1);
|
||||
assert.equal(body.openCount, 2);
|
||||
|
||||
const stillThere = await pool.query(`SELECT id FROM apps WHERE id = $1`, [
|
||||
appId,
|
||||
]);
|
||||
assert.equal(stillThere.rowCount, 1);
|
||||
});
|
||||
|
||||
test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async () => {
|
||||
const slug = `force_app_${Date.now().toString(36)}`;
|
||||
const a = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
|
||||
VALUES ($1, 'تطبيق', 'App Force', '/z', 999) RETURNING id`,
|
||||
[slug],
|
||||
);
|
||||
const appId = a.rows[0].id;
|
||||
|
||||
const g = await pool.query(
|
||||
`INSERT INTO groups (name, description_en) VALUES ($1, 'g') RETURNING id`,
|
||||
[`gAppF_${Date.now().toString(36)}`],
|
||||
);
|
||||
const groupId = g.rows[0].id;
|
||||
createdGroupIds.push(groupId);
|
||||
await pool.query(
|
||||
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
||||
[groupId, appId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`,
|
||||
[adminId, appId],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/apps/${appId}?force=true`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const check = await pool.query(`SELECT id FROM apps WHERE id = $1`, [appId]);
|
||||
assert.equal(check.rowCount, 0);
|
||||
|
||||
const audit = await pool.query(
|
||||
`SELECT * FROM audit_logs WHERE action = 'app.force_delete' AND target_id = $1`,
|
||||
[String(appId)],
|
||||
);
|
||||
assert.equal(audit.rowCount, 1);
|
||||
});
|
||||
|
||||
// ---------- Services ----------
|
||||
|
||||
test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => {
|
||||
const s = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', 'Clean Svc', 1.00, true) RETURNING id`,
|
||||
);
|
||||
const id = s.rows[0].id;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/services/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
});
|
||||
|
||||
test("DELETE /api/services/:id with orders returns 409 with counts", async () => {
|
||||
const s = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', 'Busy Svc', 2.00, true) RETURNING id`,
|
||||
);
|
||||
const sid = s.rows[0].id;
|
||||
createdServiceIds.push(sid);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending'), ($1, $2, 'pending'), ($1, $2, 'completed')`,
|
||||
[adminId, sid],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/services/${sid}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 409);
|
||||
const body = await res.json();
|
||||
assert.equal(body.orderCount, 3);
|
||||
|
||||
const stillThere = await pool.query(`SELECT id FROM services WHERE id = $1`, [
|
||||
sid,
|
||||
]);
|
||||
assert.equal(stillThere.rowCount, 1);
|
||||
});
|
||||
|
||||
test("DELETE /api/services/:id?force=true deletes service + orders and writes audit log", async () => {
|
||||
const s = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', 'Force Svc', 3.00, true) RETURNING id`,
|
||||
);
|
||||
const sid = s.rows[0].id;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending')`,
|
||||
[adminId, sid],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/services/${sid}?force=true`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const check = await pool.query(`SELECT id FROM services WHERE id = $1`, [
|
||||
sid,
|
||||
]);
|
||||
assert.equal(check.rowCount, 0);
|
||||
|
||||
const ordersGone = await pool.query(
|
||||
`SELECT id FROM service_orders WHERE service_id = $1`,
|
||||
[sid],
|
||||
);
|
||||
assert.equal(ordersGone.rowCount, 0);
|
||||
|
||||
const audit = await pool.query(
|
||||
`SELECT * FROM audit_logs WHERE action = 'service.force_delete' AND target_id = $1`,
|
||||
[String(sid)],
|
||||
);
|
||||
assert.equal(audit.rowCount, 1);
|
||||
});
|
||||
@@ -305,6 +305,38 @@
|
||||
"editService": "تعديل الخدمة",
|
||||
"editUser": "تعديل المستخدم",
|
||||
"deleteConfirm": "هل أنت متأكد من الحذف؟",
|
||||
"deleteApp": {
|
||||
"title": "حذف التطبيق \"{{name}}\"؟",
|
||||
"warning": "هذا التطبيق لا يزال قيد الاستخدام. حذفه سيؤثر على ما يلي:",
|
||||
"forceHint": "سيؤدي ذلك إلى إلغاء وصول كل من يستخدمه وإزالة سجلّه. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"emptyBody": "هل أنت متأكد من حذف هذا التطبيق؟",
|
||||
"groupCount": "{{count}} مجموعة تمنح هذا التطبيق حالياً",
|
||||
"restrictionCount": "{{count}} قاعدة صلاحيات تقيّده",
|
||||
"openCount": "{{count}} مرة فتح في السجل",
|
||||
"confirm": "حذف",
|
||||
"anyway": "حذف رغم ذلك"
|
||||
},
|
||||
"deleteService": {
|
||||
"title": "حذف الخدمة \"{{name}}\"؟",
|
||||
"warning": "هذه الخدمة لا تزال مرتبطة بطلبات. حذفها سيؤثر على ما يلي:",
|
||||
"forceHint": "سيؤدي ذلك إلى حذف كل الطلبات المرتبطة نهائياً. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"emptyBody": "هل أنت متأكد من حذف هذه الخدمة؟",
|
||||
"orderCount": "{{count}} طلب",
|
||||
"confirm": "حذف",
|
||||
"anyway": "حذف رغم ذلك"
|
||||
},
|
||||
"deleteUser": {
|
||||
"title": "حذف المستخدم \"{{name}}\"؟",
|
||||
"warning": "هذا المستخدم لديه بيانات. حذفه سيؤثر على ما يلي:",
|
||||
"forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته والمحادثات التي أنشأها والرسائل التي أرسلها بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"emptyBody": "هل أنت متأكد من حذف هذا المستخدم؟",
|
||||
"noteCount": "{{count}} ملاحظة",
|
||||
"orderCount": "{{count}} طلب",
|
||||
"conversationCount": "{{count}} محادثة أنشأها",
|
||||
"messageCount": "{{count}} رسالة أرسلها",
|
||||
"confirm": "حذف",
|
||||
"anyway": "حذف رغم ذلك"
|
||||
},
|
||||
"activate": "تفعيل",
|
||||
"deactivate": "تعطيل",
|
||||
"appName": "اسم التطبيق",
|
||||
|
||||
@@ -302,6 +302,38 @@
|
||||
"editService": "Edit Service",
|
||||
"editUser": "Edit User",
|
||||
"deleteConfirm": "Are you sure you want to delete?",
|
||||
"deleteApp": {
|
||||
"title": "Delete app \"{{name}}\"?",
|
||||
"warning": "This app is still in use. Deleting it will affect the following:",
|
||||
"forceHint": "This will revoke access for everyone using it and remove its history. This cannot be undone.",
|
||||
"emptyBody": "Are you sure you want to delete this app?",
|
||||
"groupCount": "{{count}} group(s) currently grant this app",
|
||||
"restrictionCount": "{{count}} permission rule(s) restricting it",
|
||||
"openCount": "{{count}} open event(s) in history",
|
||||
"confirm": "Delete",
|
||||
"anyway": "Delete anyway"
|
||||
},
|
||||
"deleteService": {
|
||||
"title": "Delete service \"{{name}}\"?",
|
||||
"warning": "This service still has orders. Deleting it will affect the following:",
|
||||
"forceHint": "This will permanently remove every related order. This cannot be undone.",
|
||||
"emptyBody": "Are you sure you want to delete this service?",
|
||||
"orderCount": "{{count}} order(s)",
|
||||
"confirm": "Delete",
|
||||
"anyway": "Delete anyway"
|
||||
},
|
||||
"deleteUser": {
|
||||
"title": "Delete user \"{{name}}\"?",
|
||||
"warning": "This user owns data. Deleting them will affect the following:",
|
||||
"forceHint": "This will permanently remove their notes, orders, conversations they started, and messages they sent. This cannot be undone.",
|
||||
"emptyBody": "Are you sure you want to delete this user?",
|
||||
"noteCount": "{{count}} note(s)",
|
||||
"orderCount": "{{count}} order(s)",
|
||||
"conversationCount": "{{count}} conversation(s) created",
|
||||
"messageCount": "{{count}} message(s) sent",
|
||||
"confirm": "Delete",
|
||||
"anyway": "Delete anyway"
|
||||
},
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"appName": "App Name",
|
||||
|
||||
@@ -47,6 +47,9 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import type {
|
||||
GroupDeletionConflict,
|
||||
AppDeletionConflict,
|
||||
ServiceDeletionConflict,
|
||||
UserDeletionConflict,
|
||||
App,
|
||||
Service,
|
||||
UserProfile,
|
||||
@@ -137,6 +140,82 @@ type ServiceForm = {
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
function DeletionWarningDialog({
|
||||
title,
|
||||
items,
|
||||
warning,
|
||||
forceHint,
|
||||
emptyBody,
|
||||
confirmLabel,
|
||||
anywayLabel,
|
||||
showWarning,
|
||||
isPending,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
testId,
|
||||
confirmTestId,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
warning: string;
|
||||
forceHint: string;
|
||||
emptyBody: string;
|
||||
confirmLabel: string;
|
||||
anywayLabel: string;
|
||||
showWarning: boolean;
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
testId?: string;
|
||||
confirmTestId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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={testId}
|
||||
>
|
||||
<h2 className="font-semibold text-foreground">{title}</h2>
|
||||
{showWarning ? (
|
||||
<>
|
||||
<p className="text-sm text-destructive">{warning}</p>
|
||||
{items.length > 0 && (
|
||||
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{forceHint}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{emptyBody}</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
data-testid={confirmTestId}
|
||||
>
|
||||
{showWarning ? anywayLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -224,6 +303,59 @@ export default function AdminPage() {
|
||||
|
||||
const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null);
|
||||
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
|
||||
const [appDeleteTarget, setAppDeleteTarget] = useState<App | null>(null);
|
||||
const [appDeleteConflict, setAppDeleteConflict] = useState<AppDeletionConflict | null>(null);
|
||||
const [serviceDeleteTarget, setServiceDeleteTarget] = useState<Service | null>(null);
|
||||
const [serviceDeleteConflict, setServiceDeleteConflict] = useState<ServiceDeletionConflict | null>(null);
|
||||
|
||||
const closeAppDeleteDialog = () => {
|
||||
setAppDeleteTarget(null);
|
||||
setAppDeleteConflict(null);
|
||||
};
|
||||
const closeServiceDeleteDialog = () => {
|
||||
setServiceDeleteTarget(null);
|
||||
setServiceDeleteConflict(null);
|
||||
};
|
||||
|
||||
const performAppDelete = (app: App, force: boolean) => {
|
||||
deleteApp.mutate(
|
||||
{ id: app.id, params: force ? { force: true } : undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
|
||||
toast({ title: t("common.success") });
|
||||
closeAppDeleteDialog();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409 && err.data) {
|
||||
setAppDeleteConflict(err.data as AppDeletionConflict);
|
||||
return;
|
||||
}
|
||||
toast({ title: t("common.error"), variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const performServiceDelete = (service: Service, force: boolean) => {
|
||||
deleteService.mutate(
|
||||
{ id: service.id, params: force ? { force: true } : undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() });
|
||||
toast({ title: t("common.success") });
|
||||
closeServiceDeleteDialog();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409 && err.data) {
|
||||
setServiceDeleteConflict(err.data as ServiceDeletionConflict);
|
||||
return;
|
||||
}
|
||||
toast({ title: t("common.error"), variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
|
||||
const [section, setSection] = useState<AdminSection>("dashboard");
|
||||
const [navOpenGroups, setNavOpenGroups] = useState<Record<string, boolean>>({});
|
||||
@@ -702,14 +834,11 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t("admin.deleteConfirm"))) {
|
||||
deleteApp.mutate(
|
||||
{ id: app.id },
|
||||
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
|
||||
);
|
||||
}
|
||||
setAppDeleteConflict(null);
|
||||
setAppDeleteTarget(app);
|
||||
}}
|
||||
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
data-testid={`app-delete-${app.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -764,14 +893,11 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t("admin.deleteConfirm"))) {
|
||||
deleteService.mutate(
|
||||
{ id: service.id },
|
||||
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }) },
|
||||
);
|
||||
}
|
||||
setServiceDeleteConflict(null);
|
||||
setServiceDeleteTarget(service);
|
||||
}}
|
||||
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
data-testid={`service-delete-${service.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -818,6 +944,61 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appDeleteTarget && (() => {
|
||||
const target = appDeleteTarget;
|
||||
const items: string[] = [];
|
||||
if (appDeleteConflict) {
|
||||
if (appDeleteConflict.groupCount > 0)
|
||||
items.push(t("admin.deleteApp.groupCount", { count: appDeleteConflict.groupCount }));
|
||||
if (appDeleteConflict.restrictionCount > 0)
|
||||
items.push(t("admin.deleteApp.restrictionCount", { count: appDeleteConflict.restrictionCount }));
|
||||
if (appDeleteConflict.openCount > 0)
|
||||
items.push(t("admin.deleteApp.openCount", { count: appDeleteConflict.openCount }));
|
||||
}
|
||||
return (
|
||||
<DeletionWarningDialog
|
||||
title={t("admin.deleteApp.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
|
||||
warning={t("admin.deleteApp.warning")}
|
||||
forceHint={t("admin.deleteApp.forceHint")}
|
||||
emptyBody={t("admin.deleteApp.emptyBody")}
|
||||
confirmLabel={t("admin.deleteApp.confirm")}
|
||||
anywayLabel={t("admin.deleteApp.anyway")}
|
||||
items={items}
|
||||
showWarning={appDeleteConflict !== null}
|
||||
isPending={deleteApp.isPending}
|
||||
onCancel={closeAppDeleteDialog}
|
||||
onConfirm={() => performAppDelete(target, appDeleteConflict !== null)}
|
||||
testId="app-delete-dialog"
|
||||
confirmTestId="app-delete-confirm"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{serviceDeleteTarget && (() => {
|
||||
const target = serviceDeleteTarget;
|
||||
const items: string[] = [];
|
||||
if (serviceDeleteConflict && serviceDeleteConflict.orderCount > 0) {
|
||||
items.push(t("admin.deleteService.orderCount", { count: serviceDeleteConflict.orderCount }));
|
||||
}
|
||||
return (
|
||||
<DeletionWarningDialog
|
||||
title={t("admin.deleteService.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
|
||||
warning={t("admin.deleteService.warning")}
|
||||
forceHint={t("admin.deleteService.forceHint")}
|
||||
emptyBody={t("admin.deleteService.emptyBody")}
|
||||
confirmLabel={t("admin.deleteService.confirm")}
|
||||
anywayLabel={t("admin.deleteService.anyway")}
|
||||
items={items}
|
||||
showWarning={serviceDeleteConflict !== null}
|
||||
isPending={deleteService.isPending}
|
||||
onCancel={closeServiceDeleteDialog}
|
||||
onConfirm={() => performServiceDelete(target, serviceDeleteConflict !== null)}
|
||||
testId="service-delete-dialog"
|
||||
confirmTestId="service-delete-confirm"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{resetLinkUser && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@@ -2010,6 +2191,8 @@ function UsersPanel({
|
||||
const [sortKey, setSortKey] = useState<UserSortKey>("username");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [editingUser, setEditingUser] = useState<UserProfile | null>(null);
|
||||
const [userDeleteTarget, setUserDeleteTarget] = useState<UserProfile | null>(null);
|
||||
const [userDeleteConflict, setUserDeleteConflict] = useState<UserDeletionConflict | null>(null);
|
||||
const [newUserOpen, setNewUserOpen] = useState(false);
|
||||
const [newUserForm, setNewUserForm] = useState<{
|
||||
username: string;
|
||||
@@ -2046,6 +2229,31 @@ function UsersPanel({
|
||||
const invalidateUsers = () =>
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/users`] });
|
||||
|
||||
const closeUserDeleteDialog = () => {
|
||||
setUserDeleteTarget(null);
|
||||
setUserDeleteConflict(null);
|
||||
};
|
||||
|
||||
const performUserDelete = (target: UserProfile, force: boolean) => {
|
||||
deleteUser.mutate(
|
||||
{ id: target.id, params: force ? { force: true } : undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidateUsers();
|
||||
toast({ title: t("common.success") });
|
||||
closeUserDeleteDialog();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409 && err.data) {
|
||||
setUserDeleteConflict(err.data as UserDeletionConflict);
|
||||
return;
|
||||
}
|
||||
toast({ title: t("common.error"), variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="glass-panel rounded-2xl p-3 space-y-2">
|
||||
@@ -2198,11 +2406,11 @@ function UsersPanel({
|
||||
{u.id !== currentUserId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t("admin.deleteConfirm"))) {
|
||||
deleteUser.mutate({ id: u.id }, { onSuccess: invalidateUsers });
|
||||
}
|
||||
setUserDeleteConflict(null);
|
||||
setUserDeleteTarget(u);
|
||||
}}
|
||||
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
data-testid={`user-delete-${u.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -2303,6 +2511,38 @@ function UsersPanel({
|
||||
onSaved={() => { invalidateUsers(); setEditingUser(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{userDeleteTarget && (() => {
|
||||
const target = userDeleteTarget;
|
||||
const items: string[] = [];
|
||||
if (userDeleteConflict) {
|
||||
if (userDeleteConflict.noteCount > 0)
|
||||
items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount }));
|
||||
if (userDeleteConflict.orderCount > 0)
|
||||
items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount }));
|
||||
if (userDeleteConflict.conversationCount > 0)
|
||||
items.push(t("admin.deleteUser.conversationCount", { count: userDeleteConflict.conversationCount }));
|
||||
if (userDeleteConflict.messageCount > 0)
|
||||
items.push(t("admin.deleteUser.messageCount", { count: userDeleteConflict.messageCount }));
|
||||
}
|
||||
return (
|
||||
<DeletionWarningDialog
|
||||
title={t("admin.deleteUser.title", { name: target.username })}
|
||||
warning={t("admin.deleteUser.warning")}
|
||||
forceHint={t("admin.deleteUser.forceHint")}
|
||||
emptyBody={t("admin.deleteUser.emptyBody")}
|
||||
confirmLabel={t("admin.deleteUser.confirm")}
|
||||
anywayLabel={t("admin.deleteUser.anyway")}
|
||||
items={items}
|
||||
showWarning={userDeleteConflict !== null}
|
||||
isPending={deleteUser.isPending}
|
||||
onCancel={closeUserDeleteDialog}
|
||||
onConfirm={() => performUserDelete(target, userDeleteConflict !== null)}
|
||||
testId="user-delete-dialog"
|
||||
confirmTestId="user-delete-confirm"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +223,26 @@ export interface GroupDeletionConflict {
|
||||
roleCount: number;
|
||||
}
|
||||
|
||||
export interface UserDeletionConflict {
|
||||
error: string;
|
||||
noteCount: number;
|
||||
orderCount: number;
|
||||
conversationCount: number;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface AppDeletionConflict {
|
||||
error: string;
|
||||
groupCount: number;
|
||||
restrictionCount: number;
|
||||
openCount: number;
|
||||
}
|
||||
|
||||
export interface ServiceDeletionConflict {
|
||||
error: string;
|
||||
orderCount: number;
|
||||
}
|
||||
|
||||
export interface GroupDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -802,6 +822,20 @@ export interface AuditLogList {
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export type DeleteAppParams = {
|
||||
/**
|
||||
* Force deletion of an app that has dependent records (records an audit log entry).
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type DeleteServiceParams = {
|
||||
/**
|
||||
* Force deletion of a service that has existing orders (records an audit log entry).
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type LeaveConversationBody = {
|
||||
/** When the leaver is the only admin, optionally name a specific
|
||||
remaining member to promote. Omit (or send null) to keep the
|
||||
@@ -833,6 +867,13 @@ export const ListUsersStatus = {
|
||||
disabled: "disabled",
|
||||
} as const;
|
||||
|
||||
export type DeleteUserParams = {
|
||||
/**
|
||||
* Force deletion of a user that owns dependent records (records an audit log entry).
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type ListGroupsParams = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
AdminResetLinkResponse,
|
||||
AdminStats,
|
||||
App,
|
||||
AppDeletionConflict,
|
||||
AppSettings,
|
||||
AuditLogList,
|
||||
AuthUser,
|
||||
@@ -35,7 +36,10 @@ import type {
|
||||
CreateServiceBody,
|
||||
CreateServiceOrderBody,
|
||||
CreateUserBody,
|
||||
DeleteAppParams,
|
||||
DeleteGroupParams,
|
||||
DeleteServiceParams,
|
||||
DeleteUserParams,
|
||||
ErrorResponse,
|
||||
ForgotPasswordBody,
|
||||
ForgotPasswordResponse,
|
||||
@@ -63,6 +67,7 @@ import type {
|
||||
SendMessageBody,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDeletionConflict,
|
||||
ServiceOrder,
|
||||
SuccessResponse,
|
||||
UpdateAppBody,
|
||||
@@ -78,6 +83,7 @@ import type {
|
||||
UpdateServiceBody,
|
||||
UpdateServiceOrderStatusBody,
|
||||
UpdateUserBody,
|
||||
UserDeletionConflict,
|
||||
UserProfile,
|
||||
VerifyResetTokenBody,
|
||||
VerifyResetTokenResponse,
|
||||
@@ -1399,35 +1405,48 @@ export const useUpdateApp = <
|
||||
/**
|
||||
* @summary Delete app (admin)
|
||||
*/
|
||||
export const getDeleteAppUrl = (id: number) => {
|
||||
return `/api/apps/${id}`;
|
||||
export const getDeleteAppUrl = (id: number, params?: DeleteAppParams) => {
|
||||
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/apps/${id}?${stringifiedParams}`
|
||||
: `/api/apps/${id}`;
|
||||
};
|
||||
|
||||
export const deleteApp = async (
|
||||
id: number,
|
||||
params?: DeleteAppParams,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getDeleteAppUrl(id), {
|
||||
return customFetch<void>(getDeleteAppUrl(id, params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteAppMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<AppDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deleteApp"];
|
||||
@@ -1441,11 +1460,11 @@ export const getDeleteAppMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
{ id: number }
|
||||
{ id: number; params?: DeleteAppParams }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, params } = props ?? {};
|
||||
|
||||
return deleteApp(id, requestOptions);
|
||||
return deleteApp(id, params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -1455,26 +1474,26 @@ export type DeleteAppMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteApp>>
|
||||
>;
|
||||
|
||||
export type DeleteAppMutationError = ErrorType<unknown>;
|
||||
export type DeleteAppMutationError = ErrorType<AppDeletionConflict>;
|
||||
|
||||
/**
|
||||
* @summary Delete app (admin)
|
||||
*/
|
||||
export const useDeleteApp = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<AppDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteAppMutationOptions(options));
|
||||
@@ -2235,35 +2254,51 @@ export const useUpdateService = <
|
||||
/**
|
||||
* @summary Delete service (admin)
|
||||
*/
|
||||
export const getDeleteServiceUrl = (id: number) => {
|
||||
return `/api/services/${id}`;
|
||||
export const getDeleteServiceUrl = (
|
||||
id: number,
|
||||
params?: DeleteServiceParams,
|
||||
) => {
|
||||
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/services/${id}?${stringifiedParams}`
|
||||
: `/api/services/${id}`;
|
||||
};
|
||||
|
||||
export const deleteService = async (
|
||||
id: number,
|
||||
params?: DeleteServiceParams,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getDeleteServiceUrl(id), {
|
||||
return customFetch<void>(getDeleteServiceUrl(id, params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<ServiceDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deleteService"];
|
||||
@@ -2277,11 +2312,11 @@ export const getDeleteServiceMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
{ id: number }
|
||||
{ id: number; params?: DeleteServiceParams }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, params } = props ?? {};
|
||||
|
||||
return deleteService(id, requestOptions);
|
||||
return deleteService(id, params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -2291,26 +2326,26 @@ export type DeleteServiceMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteService>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceMutationError = ErrorType<unknown>;
|
||||
export type DeleteServiceMutationError = ErrorType<ServiceDeletionConflict>;
|
||||
|
||||
/**
|
||||
* @summary Delete service (admin)
|
||||
*/
|
||||
export const useDeleteService = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<ServiceDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceMutationOptions(options));
|
||||
@@ -4340,35 +4375,48 @@ export const useUpdateUser = <
|
||||
/**
|
||||
* @summary Delete user (admin)
|
||||
*/
|
||||
export const getDeleteUserUrl = (id: number) => {
|
||||
return `/api/users/${id}`;
|
||||
export const getDeleteUserUrl = (id: number, params?: DeleteUserParams) => {
|
||||
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/users/${id}?${stringifiedParams}`
|
||||
: `/api/users/${id}`;
|
||||
};
|
||||
|
||||
export const deleteUser = async (
|
||||
id: number,
|
||||
params?: DeleteUserParams,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getDeleteUserUrl(id), {
|
||||
return customFetch<void>(getDeleteUserUrl(id, params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteUserMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<UserDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deleteUser"];
|
||||
@@ -4382,11 +4430,11 @@ export const getDeleteUserMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
{ id: number }
|
||||
{ id: number; params?: DeleteUserParams }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, params } = props ?? {};
|
||||
|
||||
return deleteUser(id, requestOptions);
|
||||
return deleteUser(id, params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -4396,26 +4444,26 @@ export type DeleteUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteUser>>
|
||||
>;
|
||||
|
||||
export type DeleteUserMutationError = ErrorType<unknown>;
|
||||
export type DeleteUserMutationError = ErrorType<UserDeletionConflict>;
|
||||
|
||||
/**
|
||||
* @summary Delete user (admin)
|
||||
*/
|
||||
export const useDeleteUser = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<UserDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteUserMutationOptions(options));
|
||||
|
||||
@@ -388,9 +388,22 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: force
|
||||
in: query
|
||||
required: false
|
||||
description: Force deletion of an app that has dependent records (records an audit log entry).
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"409":
|
||||
description: App has dependent records; pass force=true to confirm deletion.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDeletionConflict"
|
||||
|
||||
/apps/{id}/open:
|
||||
post:
|
||||
@@ -590,9 +603,22 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: force
|
||||
in: query
|
||||
required: false
|
||||
description: Force deletion of a service that has existing orders (records an audit log entry).
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"409":
|
||||
description: Service has dependent records; pass force=true to confirm deletion.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceDeletionConflict"
|
||||
|
||||
/orders:
|
||||
post:
|
||||
@@ -1143,9 +1169,22 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: force
|
||||
in: query
|
||||
required: false
|
||||
description: Force deletion of a user that owns dependent records (records an audit log entry).
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"409":
|
||||
description: User has dependent records; pass force=true to confirm deletion.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDeletionConflict"
|
||||
|
||||
/orders/{id}:
|
||||
delete:
|
||||
@@ -2129,6 +2168,54 @@ components:
|
||||
- appCount
|
||||
- roleCount
|
||||
|
||||
UserDeletionConflict:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
noteCount:
|
||||
type: integer
|
||||
orderCount:
|
||||
type: integer
|
||||
conversationCount:
|
||||
type: integer
|
||||
messageCount:
|
||||
type: integer
|
||||
required:
|
||||
- error
|
||||
- noteCount
|
||||
- orderCount
|
||||
- conversationCount
|
||||
- messageCount
|
||||
|
||||
AppDeletionConflict:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
groupCount:
|
||||
type: integer
|
||||
restrictionCount:
|
||||
type: integer
|
||||
openCount:
|
||||
type: integer
|
||||
required:
|
||||
- error
|
||||
- groupCount
|
||||
- restrictionCount
|
||||
- openCount
|
||||
|
||||
ServiceDeletionConflict:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
orderCount:
|
||||
type: integer
|
||||
required:
|
||||
- error
|
||||
- orderCount
|
||||
|
||||
GroupDetail:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -382,6 +382,17 @@ export const DeleteAppParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteAppQueryForceDefault = false;
|
||||
|
||||
export const DeleteAppQueryParams = zod.object({
|
||||
force: zod.coerce
|
||||
.boolean()
|
||||
.default(deleteAppQueryForceDefault)
|
||||
.describe(
|
||||
"Force deletion of an app that has dependent records (records an audit log entry).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Log an app open event for the current user
|
||||
*/
|
||||
@@ -591,6 +602,17 @@ export const DeleteServiceParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteServiceQueryForceDefault = false;
|
||||
|
||||
export const DeleteServiceQueryParams = zod.object({
|
||||
force: zod.coerce
|
||||
.boolean()
|
||||
.default(deleteServiceQueryForceDefault)
|
||||
.describe(
|
||||
"Force deletion of a service that has existing orders (records an audit log entry).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Place a service order
|
||||
*/
|
||||
@@ -1500,6 +1522,17 @@ export const DeleteUserParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteUserQueryForceDefault = false;
|
||||
|
||||
export const DeleteUserQueryParams = zod.object({
|
||||
force: zod.coerce
|
||||
.boolean()
|
||||
.default(deleteUserQueryForceDefault)
|
||||
.describe(
|
||||
"Force deletion of a user that owns dependent records (records an audit log entry).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Permanently deletes the order. Allowed when the caller owns the order
|
||||
AND it is in a terminal status (completed/cancelled), OR the caller is
|
||||
|
||||
Reference in New Issue
Block a user