Show readable summaries instead of raw JSON in the audit log

Task #94: Each admin audit log row now renders a localized one-line
summary derived from the entry's action + metadata, e.g.
"Deleted group 'audit-test-group'" or "تم حذف المجموعة 'X'", instead
of just showing the raw target type/id.

Implementation
- Added `formatAuditSummary(entry, t, lang)` in
  `artifacts/tx-os/src/pages/admin.tsx` covering all known audit
  actions emitted by the API (group/user/role/app/service/settings/
  auth create/update/delete/permission/membership variants, including
  forced deletions). Returns null for unknown actions so the row
  gracefully falls back to the existing "Target: type #id" display.
- The summary now occupies the prominent text slot in `AuditLogRow`.
  The raw action code stays in the small badge (it is also the value
  the action-filter dropdown uses), and the timestamp moved next to
  it. The expand toggle still reveals the full JSON metadata for
  power users (data-testid `audit-metadata-<id>` unchanged).
- Added `admin.audit.summary.*` keys plus a pluralized
  `admin.audit.unit.*` helper map in en.json and ar.json. The Arabic
  unit keys provide all six CLDR plural forms (zero/one/two/few/
  many/other) so counts read naturally; English uses one/other.

Verification
- `tsc --build` clean for the tx-os artifact.
- e2e test (admin login → create + delete a uniquely named group →
  open Audit Log → confirm Arabic and English summaries render,
  raw JSON is still available behind the toggle, and the action pill
  still shows the raw action code) passed.

Follow-up proposed
- #117 "Show readable action names in the audit log filter dropdown"
  (the filter dropdown still lists raw action codes).

Replit-Task-Id: b47aacde-087e-4a2b-8fb2-0e63cb1936e4
This commit is contained in:
riyadhafraa
2026-04-28 21:07:19 +00:00
parent efb3f512b7
commit 9a3cf120ce
4 changed files with 395 additions and 7 deletions
+219 -7
View File
@@ -3706,6 +3706,208 @@ function actorLabel(actor: AuditLogEntry["actor"], lang: string): string {
return preferred && preferred.trim() ? preferred : actor.username;
}
type AuditTFunction = ReturnType<typeof useTranslation>["t"];
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asString(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function asNumber(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function unitLabel(t: AuditTFunction, kind: string, count: number): string {
return t(`admin.audit.unit.${kind}`, { count });
}
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 preferred = lang === "ar" ? ar ?? en : en ?? ar;
return preferred ?? slug ?? `#${fallbackId ?? "?"}`;
}
function plainName(meta: Record<string, unknown>, fallbackId: AuditLogEntry["targetId"]): string {
return asString(meta.name) ?? `#${fallbackId ?? "?"}`;
}
function changeCount(meta: Record<string, unknown>): number {
const changes = meta.changes;
if (changes && typeof changes === "object" && !Array.isArray(changes)) {
return Object.keys(changes as Record<string, unknown>).length;
}
if (Array.isArray(changes)) return changes.length;
let count = 0;
for (const key of ["members", "apps", "roles"]) {
const diff = meta[key];
if (diff && typeof diff === "object" && !Array.isArray(diff)) {
const d = diff as Record<string, unknown>;
const added = Array.isArray(d.added) ? d.added.length : 0;
const removed = Array.isArray(d.removed) ? d.removed.length : 0;
if (added + removed > 0) count += 1;
}
}
return count;
}
function formatAuditSummary(
entry: AuditLogEntry,
t: AuditTFunction,
lang: string,
): string | null {
const action = entry.action;
const meta = asRecord(entry.metadata);
const targetId = entry.targetId;
switch (action) {
case "app.create":
return t("admin.audit.summary.app.create", {
name: appName(meta, lang, targetId),
});
case "app.update":
return t("admin.audit.summary.app.update", {
name: appName(meta, lang, targetId),
changes: unitLabel(t, "change", changeCount(meta)),
});
case "app.delete": {
const name = appName(meta, lang, targetId);
if (meta.force) {
const groupCount = asNumber(meta.groupCount) ?? 0;
return t("admin.audit.summary.app.forceDelete", {
name,
groups: unitLabel(t, "group", groupCount),
});
}
return t("admin.audit.summary.app.delete", { name });
}
case "auth.issue_reset_link":
return t("admin.audit.summary.auth.issueResetLink", {
username: asString(meta.username) ?? `#${targetId ?? "?"}`,
});
case "group.create":
return t("admin.audit.summary.group.create", {
name: plainName(meta, targetId),
members: unitLabel(t, "member", asNumber(meta.memberCount) ?? 0),
apps: unitLabel(t, "app", asNumber(meta.appCount) ?? 0),
});
case "group.update":
return t("admin.audit.summary.group.update", {
name: plainName(meta, targetId),
changes: unitLabel(t, "change", Math.max(1, changeCount(meta))),
});
case "group.delete": {
const name = plainName(meta, targetId);
if (meta.force) {
const memberCount = asNumber(meta.memberCount) ?? 0;
return t("admin.audit.summary.group.forceDelete", {
name,
members: unitLabel(t, "member", memberCount),
});
}
return t("admin.audit.summary.group.delete", { name });
}
case "group.user.add":
return t("admin.audit.summary.group.userAdd", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
userId: asNumber(meta.userId) ?? "?",
});
case "group.user.remove":
return t("admin.audit.summary.group.userRemove", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
userId: asNumber(meta.userId) ?? "?",
});
case "group.app.add":
return t("admin.audit.summary.group.appAdd", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
appId: asNumber(meta.appId) ?? "?",
});
case "group.app.remove":
return t("admin.audit.summary.group.appRemove", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
appId: asNumber(meta.appId) ?? "?",
});
case "group.role.add":
return t("admin.audit.summary.group.roleAdd", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
roleId: asNumber(meta.roleId) ?? "?",
});
case "group.role.remove":
return t("admin.audit.summary.group.roleRemove", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
roleId: asNumber(meta.roleId) ?? "?",
});
case "role.create":
return t("admin.audit.summary.role.create", {
name: plainName(meta, targetId),
});
case "role.update": {
const previousName = asString(meta.previousName);
const name = plainName(meta, targetId);
if (previousName && previousName !== name) {
return t("admin.audit.summary.role.rename", {
previousName,
name,
});
}
return t("admin.audit.summary.role.update", {
name,
changes: unitLabel(t, "change", Math.max(1, changeCount(meta))),
});
}
case "role.delete":
return t("admin.audit.summary.role.delete", {
name: plainName(meta, targetId),
});
case "role.permissions.replace": {
const added = Array.isArray(meta.added) ? meta.added.length : 0;
const removed = Array.isArray(meta.removed) ? meta.removed.length : 0;
return t("admin.audit.summary.role.permissionsReplace", {
name: asString(meta.roleName) ?? `#${targetId ?? "?"}`,
added: unitLabel(t, "permission", added),
removed: unitLabel(t, "permission", removed),
});
}
case "role.permission.add":
return t("admin.audit.summary.role.permissionAdd", {
name: asString(meta.roleName) ?? `#${targetId ?? "?"}`,
permission:
asString(meta.permissionName) ??
(asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"),
});
case "role.permission.remove":
return t("admin.audit.summary.role.permissionRemove", {
name: asString(meta.roleName) ?? `#${targetId ?? "?"}`,
permission:
asString(meta.permissionName) ??
(asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"),
});
case "service.force_delete":
return t("admin.audit.summary.service.forceDelete", {
name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`,
orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0),
});
case "settings.update":
return t("admin.audit.summary.settings.update", {
changes: unitLabel(t, "change", changeCount(meta)),
});
case "user.delete": {
const username = asString(meta.username) ?? `#${targetId ?? "?"}`;
return meta.force
? t("admin.audit.summary.user.forceDelete", { username })
: t("admin.audit.summary.user.delete", { username });
}
default:
return null;
}
}
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -3713,6 +3915,7 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
entry.metadata != null && Object.keys(entry.metadata as object).length > 0;
const actor = entry.actor;
const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase();
const summary = formatAuditSummary(entry, t, lang);
return (
<div
className="glass-panel rounded-2xl p-3 space-y-2"
@@ -3735,6 +3938,21 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
</span>
)}
</div>
{summary ? (
<div
className="text-sm text-foreground mt-1 leading-snug"
data-testid={`audit-summary-${entry.id}`}
>
{summary}
</div>
) : (
<div className="text-xs text-muted-foreground mt-1">
{t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
})}
</div>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<span
className="inline-block text-[11px] font-mono px-2 py-0.5 rounded-md bg-amber-100 text-amber-800"
@@ -3743,15 +3961,9 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
{entry.action}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
})}
{formatAuditTimestamp(entry.createdAt, lang)}
</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatAuditTimestamp(entry.createdAt, lang)}
</div>
</div>
{hasMetadata && (
<button