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).
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -493,6 +493,116 @@
|
||||
"invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.",
|
||||
"invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".",
|
||||
"fixFiltersFirst": "عدّل الفلاتر لتحميل الإدخالات."
|
||||
},
|
||||
"unit": {
|
||||
"member_zero": "بدون أعضاء",
|
||||
"member_one": "عضو واحد",
|
||||
"member_two": "عضوان",
|
||||
"member_few": "{{count}} أعضاء",
|
||||
"member_many": "{{count}} عضواً",
|
||||
"member_other": "{{count}} عضو",
|
||||
"app_zero": "بدون تطبيقات",
|
||||
"app_one": "تطبيق واحد",
|
||||
"app_two": "تطبيقان",
|
||||
"app_few": "{{count}} تطبيقات",
|
||||
"app_many": "{{count}} تطبيقاً",
|
||||
"app_other": "{{count}} تطبيق",
|
||||
"role_zero": "بدون أدوار",
|
||||
"role_one": "دور واحد",
|
||||
"role_two": "دوران",
|
||||
"role_few": "{{count}} أدوار",
|
||||
"role_many": "{{count}} دوراً",
|
||||
"role_other": "{{count}} دور",
|
||||
"group_zero": "بدون مجموعات",
|
||||
"group_one": "مجموعة واحدة",
|
||||
"group_two": "مجموعتان",
|
||||
"group_few": "{{count}} مجموعات",
|
||||
"group_many": "{{count}} مجموعةً",
|
||||
"group_other": "{{count}} مجموعة",
|
||||
"order_zero": "بدون طلبات",
|
||||
"order_one": "طلب واحد",
|
||||
"order_two": "طلبان",
|
||||
"order_few": "{{count}} طلبات",
|
||||
"order_many": "{{count}} طلباً",
|
||||
"order_other": "{{count}} طلب",
|
||||
"change_zero": "بدون تغييرات",
|
||||
"change_one": "تغيير واحد",
|
||||
"change_two": "تغييران",
|
||||
"change_few": "{{count}} تغييرات",
|
||||
"change_many": "{{count}} تغييراً",
|
||||
"change_other": "{{count}} تغيير",
|
||||
"permission_zero": "بدون صلاحيات",
|
||||
"permission_one": "صلاحية واحدة",
|
||||
"permission_two": "صلاحيتان",
|
||||
"permission_few": "{{count}} صلاحيات",
|
||||
"permission_many": "{{count}} صلاحيةً",
|
||||
"permission_other": "{{count}} صلاحية",
|
||||
"restriction_zero": "بدون قيود",
|
||||
"restriction_one": "قيد واحد",
|
||||
"restriction_two": "قيدان",
|
||||
"restriction_few": "{{count}} قيود",
|
||||
"restriction_many": "{{count}} قيداً",
|
||||
"restriction_other": "{{count}} قيد",
|
||||
"note_zero": "بدون ملاحظات",
|
||||
"note_one": "ملاحظة واحدة",
|
||||
"note_two": "ملاحظتان",
|
||||
"note_few": "{{count}} ملاحظات",
|
||||
"note_many": "{{count}} ملاحظةً",
|
||||
"note_other": "{{count}} ملاحظة",
|
||||
"conversation_zero": "بدون محادثات",
|
||||
"conversation_one": "محادثة واحدة",
|
||||
"conversation_two": "محادثتان",
|
||||
"conversation_few": "{{count}} محادثات",
|
||||
"conversation_many": "{{count}} محادثةً",
|
||||
"conversation_other": "{{count}} محادثة",
|
||||
"message_zero": "بدون رسائل",
|
||||
"message_one": "رسالة واحدة",
|
||||
"message_two": "رسالتان",
|
||||
"message_few": "{{count}} رسائل",
|
||||
"message_many": "{{count}} رسالةً",
|
||||
"message_other": "{{count}} رسالة"
|
||||
},
|
||||
"summary": {
|
||||
"app": {
|
||||
"create": "تم إنشاء التطبيق '{{name}}'",
|
||||
"update": "تم تعديل التطبيق '{{name}}' ({{changes}})",
|
||||
"delete": "تم حذف التطبيق '{{name}}'",
|
||||
"forceDelete": "تم حذف التطبيق '{{name}}' قسراً (تأثرت {{groups}})"
|
||||
},
|
||||
"auth": {
|
||||
"issueResetLink": "تم إصدار رابط إعادة تعيين كلمة المرور للمستخدم @{{username}}"
|
||||
},
|
||||
"group": {
|
||||
"create": "تم إنشاء المجموعة '{{name}}' ({{members}}، {{apps}})",
|
||||
"update": "تم تعديل المجموعة '{{name}}' ({{changes}})",
|
||||
"delete": "تم حذف المجموعة '{{name}}'",
|
||||
"forceDelete": "تم حذف المجموعة '{{name}}' قسراً ({{members}})",
|
||||
"userAdd": "تمت إضافة المستخدم #{{userId}} إلى المجموعة '{{name}}'",
|
||||
"userRemove": "تمت إزالة المستخدم #{{userId}} من المجموعة '{{name}}'",
|
||||
"appAdd": "تمت إضافة التطبيق #{{appId}} إلى المجموعة '{{name}}'",
|
||||
"appRemove": "تمت إزالة التطبيق #{{appId}} من المجموعة '{{name}}'",
|
||||
"roleAdd": "تمت إضافة الدور #{{roleId}} إلى المجموعة '{{name}}'",
|
||||
"roleRemove": "تمت إزالة الدور #{{roleId}} من المجموعة '{{name}}'"
|
||||
},
|
||||
"role": {
|
||||
"create": "تم إنشاء الدور '{{name}}'",
|
||||
"update": "تم تعديل الدور '{{name}}' ({{changes}})",
|
||||
"rename": "تمت إعادة تسمية الدور من '{{previousName}}' إلى '{{name}}'",
|
||||
"delete": "تم حذف الدور '{{name}}'",
|
||||
"permissionsReplace": "تم تحديث صلاحيات الدور '{{name}}' (أُضيفت {{added}}، حُذفت {{removed}})",
|
||||
"permissionAdd": "تمت إضافة الصلاحية '{{permission}}' إلى الدور '{{name}}'",
|
||||
"permissionRemove": "تمت إزالة الصلاحية '{{permission}}' من الدور '{{name}}'"
|
||||
},
|
||||
"service": {
|
||||
"forceDelete": "تم حذف الخدمة '{{name}}' قسراً (تأثرت {{orders}})"
|
||||
},
|
||||
"settings": {
|
||||
"update": "تم تحديث إعدادات النظام ({{changes}})"
|
||||
},
|
||||
"user": {
|
||||
"delete": "تم حذف المستخدم @{{username}}",
|
||||
"forceDelete": "تم حذف المستخدم @{{username}} قسراً"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardSoon": "إحصائيات اللوحة قريباً.",
|
||||
|
||||
@@ -490,6 +490,72 @@
|
||||
"invalidDate": "Dates must be in YYYY-MM-DD format.",
|
||||
"invertedDates": "\"From\" date must be on or before \"To\" date.",
|
||||
"fixFiltersFirst": "Adjust the filters to load entries."
|
||||
},
|
||||
"unit": {
|
||||
"member_one": "{{count}} member",
|
||||
"member_other": "{{count}} members",
|
||||
"app_one": "{{count}} app",
|
||||
"app_other": "{{count}} apps",
|
||||
"role_one": "{{count}} role",
|
||||
"role_other": "{{count}} roles",
|
||||
"group_one": "{{count}} group",
|
||||
"group_other": "{{count}} groups",
|
||||
"order_one": "{{count}} order",
|
||||
"order_other": "{{count}} orders",
|
||||
"change_one": "{{count}} change",
|
||||
"change_other": "{{count}} changes",
|
||||
"permission_one": "{{count}} permission",
|
||||
"permission_other": "{{count}} permissions",
|
||||
"restriction_one": "{{count}} restriction",
|
||||
"restriction_other": "{{count}} restrictions",
|
||||
"note_one": "{{count}} note",
|
||||
"note_other": "{{count}} notes",
|
||||
"conversation_one": "{{count}} conversation",
|
||||
"conversation_other": "{{count}} conversations",
|
||||
"message_one": "{{count}} message",
|
||||
"message_other": "{{count}} messages"
|
||||
},
|
||||
"summary": {
|
||||
"app": {
|
||||
"create": "Created app '{{name}}'",
|
||||
"update": "Updated app '{{name}}' ({{changes}})",
|
||||
"delete": "Deleted app '{{name}}'",
|
||||
"forceDelete": "Force-deleted app '{{name}}' (affected {{groups}})"
|
||||
},
|
||||
"auth": {
|
||||
"issueResetLink": "Issued password reset link for @{{username}}"
|
||||
},
|
||||
"group": {
|
||||
"create": "Created group '{{name}}' ({{members}}, {{apps}})",
|
||||
"update": "Updated group '{{name}}' ({{changes}})",
|
||||
"delete": "Deleted group '{{name}}'",
|
||||
"forceDelete": "Force-deleted group '{{name}}' ({{members}})",
|
||||
"userAdd": "Added user #{{userId}} to group '{{name}}'",
|
||||
"userRemove": "Removed user #{{userId}} from group '{{name}}'",
|
||||
"appAdd": "Added app #{{appId}} to group '{{name}}'",
|
||||
"appRemove": "Removed app #{{appId}} from group '{{name}}'",
|
||||
"roleAdd": "Added role #{{roleId}} to group '{{name}}'",
|
||||
"roleRemove": "Removed role #{{roleId}} from group '{{name}}'"
|
||||
},
|
||||
"role": {
|
||||
"create": "Created role '{{name}}'",
|
||||
"update": "Updated role '{{name}}' ({{changes}})",
|
||||
"rename": "Renamed role '{{previousName}}' to '{{name}}'",
|
||||
"delete": "Deleted role '{{name}}'",
|
||||
"permissionsReplace": "Updated permissions for role '{{name}}' (added {{added}}, removed {{removed}})",
|
||||
"permissionAdd": "Added permission '{{permission}}' to role '{{name}}'",
|
||||
"permissionRemove": "Removed permission '{{permission}}' from role '{{name}}'"
|
||||
},
|
||||
"service": {
|
||||
"forceDelete": "Force-deleted service '{{name}}' (affected {{orders}})"
|
||||
},
|
||||
"settings": {
|
||||
"update": "Updated system settings ({{changes}})"
|
||||
},
|
||||
"user": {
|
||||
"delete": "Deleted user @{{username}}",
|
||||
"forceDelete": "Force-deleted user @{{username}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardSoon": "Dashboard widgets coming soon.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user