Show readable names in audit log for top-level deletions

Task: #177 — Make user/app/role deletion audit rows render readable
names ("Deleted user @alice (Alice Smith)", "Deleted app 'Notes'",
"Deleted role 'Editor'") instead of relying on whatever the route
happened to capture.

Backend metadata changes:
- artifacts/api-server/src/routes/users.ts (user.delete): now also
  persists displayNameEn and displayNameAr alongside the existing
  username/email.
- artifacts/api-server/src/routes/apps.ts (app.delete): renamed the
  metadata keys slug/nameAr/nameEn → appSlug/appNameAr/appNameEn so
  app sub-resource events and top-level deletes share one prefix.
- artifacts/api-server/src/routes/roles.ts (role.delete): renamed the
  metadata key name → roleName, matching group.role.add/remove.

Frontend formatter (artifacts/tx-os/src/pages/admin.tsx):
- appName helper now reads both legacy (slug/nameEn/nameAr) and new
  (appSlug/appNameEn/appNameAr) keys so old rows still render.
- role.delete case prefers roleName, falls back to legacy name.
- user.delete case picks the user's localized display name and uses
  new locale strings user.deleteWithName / user.forceDeleteWithName
  when present; falls back to the existing username-only strings.
- forceDeletedEntityName also accepts appNameEn/appNameAr/appSlug so
  force-deleted apps still get their inline name chip.

Locales:
- artifacts/tx-os/src/locales/{en,ar}.json: added
  admin.audit.summary.user.deleteWithName and forceDeleteWithName.

Test updates:
- artifacts/api-server/tests/audit-log-coverage.test.mjs: updated the
  role.delete and app.delete (no-deps) assertions to read the new
  metadata key names. The user.delete assertions kept working as-is
  since username/email/force are unchanged.

No DB migration was required — audit_logs.metadata is already JSON.
Legacy rows continue to render via the formatter fallbacks called
out in the task description.
This commit is contained in:
Riyadh
2026-04-30 20:38:02 +00:00
parent 76a174cfee
commit 2c4655be31
7 changed files with 50 additions and 15 deletions
+3 -3
View File
@@ -914,9 +914,9 @@ router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
targetType: "app",
targetId: appId,
metadata: {
slug: existing.slug,
nameAr: existing.nameAr,
nameEn: existing.nameEn,
appSlug: existing.slug,
appNameAr: existing.nameAr,
appNameEn: existing.nameEn,
force,
groupCount,
restrictionCount,
+1 -1
View File
@@ -414,7 +414,7 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
targetType: "role",
targetId: id,
metadata: {
name: existing.name,
roleName: existing.name,
descriptionAr: existing.descriptionAr,
descriptionEn: existing.descriptionEn,
},
+2
View File
@@ -720,6 +720,8 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
targetId: userId,
metadata: {
username: existing.username,
displayNameEn: existing.displayNameEn,
displayNameAr: existing.displayNameAr,
email: existing.email,
force,
...(hasDeps
@@ -276,6 +276,8 @@ test("DELETE /api/users/:id (no-force, no deps) writes one user.delete row with
assert.equal(row.actor_user_id, adminId);
assert.equal(row.metadata.username, username);
assert.equal(row.metadata.email, email);
assert.equal(row.metadata.displayNameEn, "Audit Coverage");
assert.equal(row.metadata.displayNameAr, null);
assert.equal(row.metadata.force, false);
// No-deps path must NOT include the dependency counts.
assert.equal(row.metadata.noteCount, undefined);
@@ -447,7 +449,7 @@ test("DELETE /api/roles/:id (no users/groups) writes one role.delete row", async
});
assert.equal(rows.length, 1);
assert.equal(rows[0].actor_user_id, adminId);
assert.equal(rows[0].metadata.name, role.name);
assert.equal(rows[0].metadata.roleName, role.name);
});
// ---------- groups ----------
@@ -844,7 +846,9 @@ test("DELETE /api/apps/:id (no deps) writes app.delete with force=false and zero
targetType: "app",
});
assert.equal(rows.length, 1);
assert.equal(rows[0].metadata.slug, app.slug);
assert.equal(rows[0].metadata.appSlug, app.slug);
assert.equal(rows[0].metadata.appNameEn, "Audit App");
assert.equal(rows[0].metadata.appNameAr, "تطبيق تدقيق");
assert.equal(rows[0].metadata.force, false);
assert.equal(rows[0].metadata.groupCount, 0);
assert.equal(rows[0].metadata.restrictionCount, 0);
+3 -1
View File
@@ -814,7 +814,9 @@
},
"user": {
"delete": "تم حذف المستخدم @{{username}}",
"forceDelete": "تم حذف المستخدم @{{username}} قسراً"
"deleteWithName": "تم حذف المستخدم @{{username}} ({{displayName}})",
"forceDelete": "تم حذف المستخدم @{{username}} قسراً",
"forceDeleteWithName": "تم حذف المستخدم @{{username}} ({{displayName}}) قسراً"
}
}
},
+3 -1
View File
@@ -715,7 +715,9 @@
},
"user": {
"delete": "Deleted user @{{username}}",
"forceDelete": "Force-deleted user @{{username}}"
"deleteWithName": "Deleted user @{{username}} ({{displayName}})",
"forceDelete": "Force-deleted user @{{username}}",
"forceDeleteWithName": "Force-deleted user @{{username}} ({{displayName}})"
}
}
},
+32 -7
View File
@@ -5693,9 +5693,9 @@ function unitLabel(t: AuditTFunction, kind: string, count: number): string {
}
function appName(meta: Record<string, unknown>, lang: string, fallbackId: AuditLogEntry["targetId"]): string {
const en = asString(meta.nameEn);
const ar = asString(meta.nameAr);
const slug = asString(meta.slug);
const en = asString(meta.nameEn) ?? asString(meta.appNameEn);
const ar = asString(meta.nameAr) ?? asString(meta.appNameAr);
const slug = asString(meta.slug) ?? asString(meta.appSlug);
const preferred = lang === "ar" ? ar ?? en : en ?? ar;
return preferred ?? slug ?? `#${fallbackId ?? "?"}`;
}
@@ -5908,7 +5908,10 @@ function formatAuditSummary(
}
case "role.delete":
return t("admin.audit.summary.role.delete", {
name: plainName(meta, targetId),
name:
asString(meta.roleName) ??
asString(meta.name) ??
`#${targetId ?? "?"}`,
});
case "role.permissions.replace": {
const added = Array.isArray(meta.added) ? meta.added.length : 0;
@@ -5965,6 +5968,22 @@ function formatAuditSummary(
}
case "user.delete": {
const username = asString(meta.username) ?? `#${targetId ?? "?"}`;
const displayEn = asString(meta.displayNameEn);
const displayAr = asString(meta.displayNameAr);
const displayName = lang === "ar"
? displayAr ?? displayEn
: displayEn ?? displayAr;
if (displayName) {
return meta.force
? t("admin.audit.summary.user.forceDeleteWithName", {
username,
displayName,
})
: t("admin.audit.summary.user.deleteWithName", {
username,
displayName,
});
}
return meta.force
? t("admin.audit.summary.user.forceDelete", { username })
: t("admin.audit.summary.user.delete", { username });
@@ -6025,11 +6044,17 @@ function forceDeletedEntityName(
): string | null {
if (!isForceDeleteEntry(entry)) return null;
const meta = asRecord(entry.metadata);
const en = asString(meta.nameEn) ?? asString(meta.displayNameEn);
const ar = asString(meta.nameAr) ?? asString(meta.displayNameAr);
const en =
asString(meta.nameEn) ??
asString(meta.appNameEn) ??
asString(meta.displayNameEn);
const ar =
asString(meta.nameAr) ??
asString(meta.appNameAr) ??
asString(meta.displayNameAr);
const localized = lang === "ar" ? ar ?? en : en ?? ar;
if (localized) return localized;
const plain = asString(meta.name);
const plain = asString(meta.name) ?? asString(meta.appSlug);
if (plain) return plain;
const username = asString(meta.username);
if (username) return `@${username}`;