Task #196: Add inline "Recent activity" sections to admin detail panels

- Added shared `RecentActivityForTarget` component in `artifacts/tx-os/src/pages/admin.tsx` that lists the 10 most recent audit entries for a given (targetType, targetId), reusing `formatAuditSummary` for consistent wording with the audit log section, plus actor + timestamp metadata. Renders loading / empty / list states with stable data-testids.

- Added `openAuditLogForTarget(targetType, targetId)` helper inside `AdminPage`. Writes the URL hash with `section=audit-log&targetType=<t>&targetId=<n>` BEFORE calling `setSection("audit-log")` so the existing section-sync effect (which would otherwise drop section-scoped params on a section change) preserves the deep-link parameters. Also closes any open editor modals.

- Wired the component into all five entity detail panels:
  * App edit modal
  * Service edit modal
  * `UserGroupsEditor` (user edit)
  * `GroupDetailEditor` (history tab)
  * Role edit dialog
  GroupsPanel and RolesPanel previously took no props; both now accept and forward an `onOpenAuditLogForTarget` prop. `OpenAuditLogForTarget` type is shared.

- Added i18n keys `admin.audit.recent.{title,loading,empty,viewAll,viewAllAria}` in both `en.json` and `ar.json`.

- Verified end-to-end via Playwright runTest: deep-link hash, modal closure, and audit-log filter pre-population all work for app/service/group/role/user panels.

No backend changes required (the existing `targetType`/`targetId` filter on `GET /api/audit` was already supported).
This commit is contained in:
Riyadh
2026-05-01 14:58:32 +00:00
parent 20cb786744
commit 2bf6cfcdb5
4 changed files with 243 additions and 5 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+7
View File
@@ -761,6 +761,13 @@
"chipAriaTargetType": "{{label}} — فتح سجل التدقيق مفلتراً حسب نوع الهدف \"{{type}}\"",
"chipAriaParent": "{{label}} — فتح سجل التدقيق لـ {{type}} #{{id}}"
},
"recent": {
"title": "آخر النشاطات",
"loading": "جارٍ تحميل آخر النشاطات…",
"empty": "لا توجد نشاطات حديثة لهذا السجل بعد.",
"viewAll": "عرض السجل الكامل",
"viewAllAria": "فتح سجل التدقيق مفلتراً على هذا الـ {{type}}"
},
"summary": {
"app": {
"create": "تم إنشاء التطبيق '{{name}}'",
+7
View File
@@ -662,6 +662,13 @@
"chipAriaTargetType": "{{label}} — open audit log filtered by target type \"{{type}}\"",
"chipAriaParent": "{{label}} — open audit log for {{type}} #{{id}}"
},
"recent": {
"title": "Recent activity",
"loading": "Loading recent activity…",
"empty": "No recent activity for this record yet.",
"viewAll": "View full history",
"viewAllAria": "Open the audit log filtered to this {{type}}"
},
"summary": {
"app": {
"create": "Created app '{{name}}'",
+229 -5
View File
@@ -1023,6 +1023,30 @@ export default function AdminPage() {
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0, permissionIds: [] };
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", imageUrl: "", isAvailable: true };
// #196: Switch to the audit-log section pre-filtered by the given
// targetType/targetId. Writes the hash BEFORE the section change so the
// section-sync effect (which would otherwise drop targetType/targetId
// when the previous section differs) sees a matching previousSection and
// preserves the deep-link params.
const openAuditLogForTarget = (
targetType: "app" | "service" | "user" | "group" | "role",
targetId: number,
) => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(
window.location.hash.replace(/^#/, ""),
);
params.set("section", "audit-log");
params.set("targetType", targetType);
params.set("targetId", String(targetId));
window.history.replaceState(null, "", `#${params.toString()}`);
}
// Close any open editor modals so the audit-log view is unobstructed.
setEditingApp(null);
setEditingService(null);
setSection("audit-log");
};
const handleSaveApp = () => {
if (!editingApp) return;
if (editingApp.id) {
@@ -1193,6 +1217,17 @@ export default function AdminPage() {
}}
/>
)}
{editingApp.id !== undefined && (
<RecentActivityForTarget
targetType="app"
targetId={editingApp.id}
enabled={true}
lang={lang}
onViewFullHistory={() =>
openAuditLogForTarget("app", editingApp.id!)
}
/>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
@@ -1244,6 +1279,17 @@ export default function AdminPage() {
onCheckedChange={(v) => setEditingService({ ...editingService, form: { ...editingService.form, isAvailable: v } })}
/>
</div>
{editingService.id !== undefined && (
<RecentActivityForTarget
targetType="service"
targetId={editingService.id}
enabled={true}
lang={lang}
onViewFullHistory={() =>
openAuditLogForTarget("service", editingService.id!)
}
/>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingService(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleSaveService}>{t("common.save")}</Button>
@@ -1586,10 +1632,15 @@ export default function AdminPage() {
},
)
}
onOpenAuditLogForTarget={openAuditLogForTarget}
/>
)}
{section === "groups" && <GroupsPanel />}
{section === "roles" && <RolesPanel />}
{section === "groups" && (
<GroupsPanel onOpenAuditLogForTarget={openAuditLogForTarget} />
)}
{section === "roles" && (
<RolesPanel onOpenAuditLogForTarget={openAuditLogForTarget} />
)}
{section === "audit-log" && <AuditLogPanel />}
{section === "settings" && (
@@ -3323,16 +3374,23 @@ function DependencyRow({
// ===== Users Management Panel =====
type UserSortKey = "username" | "email" | "createdAt";
type OpenAuditLogForTarget = (
targetType: "app" | "service" | "user" | "group" | "role",
targetId: number,
) => void;
function UsersPanel({
currentUserId,
highlightedUserId,
userRowRefs,
onIssueResetLink,
onOpenAuditLogForTarget,
}: {
currentUserId: number;
highlightedUserId: number | null;
userRowRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
onIssueResetLink: (userId: number, username: string) => void;
onOpenAuditLogForTarget: OpenAuditLogForTarget;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -3742,6 +3800,7 @@ function UsersPanel({
groups={groups ?? []}
onClose={() => setEditingUser(null)}
onSaved={() => { invalidateUsers(); setEditingUser(null); }}
onOpenAuditLogForTarget={onOpenAuditLogForTarget}
/>
)}
@@ -3798,11 +3857,13 @@ function UserGroupsEditor({
groups,
onClose,
onSaved,
onOpenAuditLogForTarget,
}: {
user: UserProfile;
groups: Group[];
onClose: () => void;
onSaved: () => void;
onOpenAuditLogForTarget: OpenAuditLogForTarget;
}) {
const { t, i18n } = useTranslation();
const { toast } = useToast();
@@ -4047,6 +4108,13 @@ function UserGroupsEditor({
return r ? r.name : `#${id}`;
}}
/>
<RecentActivityForTarget
targetType="user"
targetId={user.id}
enabled={true}
lang={i18n.language}
onViewFullHistory={() => onOpenAuditLogForTarget("user", user.id)}
/>
<DialogFooter className="flex flex-row gap-2 pt-2 sm:justify-stretch sm:space-x-0">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
@@ -4164,7 +4232,11 @@ function UserGroupsEditor({
}
// ===== Groups Management Panel =====
function GroupsPanel() {
function GroupsPanel({
onOpenAuditLogForTarget,
}: {
onOpenAuditLogForTarget: OpenAuditLogForTarget;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -4343,6 +4415,7 @@ function GroupsPanel() {
queryClient.invalidateQueries({ queryKey: getGetGroupQueryKey(editingGroupId) });
setEditingGroupId(null);
}}
onOpenAuditLogForTarget={onOpenAuditLogForTarget}
/>
)}
@@ -4409,7 +4482,17 @@ function GroupsPanel() {
);
}
function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onClose: () => void; onSaved: () => void }) {
function GroupDetailEditor({
groupId,
onClose,
onSaved,
onOpenAuditLogForTarget,
}: {
groupId: number;
onClose: () => void;
onSaved: () => void;
onOpenAuditLogForTarget: OpenAuditLogForTarget;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { toast } = useToast();
@@ -4611,6 +4694,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
)}
{tab === "history" && (
<>
<PermissionAuditHistory
targetKind="group"
targetId={group.id}
@@ -4637,6 +4721,17 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
return a ? (lang === "ar" ? a.nameAr : a.nameEn) : `#${id}`;
}}
/>
<RecentActivityForTarget
targetType="group"
targetId={group.id}
enabled={true}
lang={lang}
onViewFullHistory={() => {
onClose();
onOpenAuditLogForTarget("group", group.id);
}}
/>
</>
)}
<div className="flex gap-2 pt-2 border-t border-slate-200/70">
@@ -5543,7 +5638,11 @@ function PermissionAuditHistory({
);
}
function RolesPanel() {
function RolesPanel({
onOpenAuditLogForTarget,
}: {
onOpenAuditLogForTarget: OpenAuditLogForTarget;
}) {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -6211,6 +6310,16 @@ function RolesPanel() {
permissionsById={permissionsById}
language={i18n.language}
/>
<RecentActivityForTarget
targetType="role"
targetId={editingPermissionsId}
enabled={editingId != null}
lang={i18n.language}
onViewFullHistory={() => {
closeEditDialog();
onOpenAuditLogForTarget("role", editingPermissionsId);
}}
/>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
{t("common.cancel")}
@@ -6611,6 +6720,121 @@ function AuditLogRow({
);
}
// #196: Inline "Recent activity" strip rendered inside each entity's detail
// editor (services / apps / groups / users / roles). Reuses the same
// formatAuditSummary wording as the full audit log so the two views read
// consistently. The "View full history" button hands back control to the
// parent so it can switch sections AND set the targetType/targetId hash —
// see openAuditLogForTarget() in AdminPage for that orchestration.
function RecentActivityForTarget({
targetType,
targetId,
enabled,
lang,
onViewFullHistory,
}: {
targetType: "app" | "service" | "user" | "group" | "role";
targetId: number;
enabled: boolean;
lang: string;
onViewFullHistory: () => void;
}) {
const { t } = useTranslation();
const params: ListAuditLogsParams = {
targetType,
targetId,
limit: 10,
offset: 0,
};
const queryKey = getListAuditLogsQueryKey(params);
const { data, isLoading } = useListAuditLogs(params, {
query: { queryKey, enabled },
});
const entries = data?.entries ?? [];
const totalCount = data?.totalCount ?? 0;
const testIdRoot = `recent-activity-${targetType}-${targetId}`;
return (
<div
className="space-y-2 pt-3 border-t border-slate-200/70"
data-testid={testIdRoot}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">
{t("admin.audit.recent.title")}
</h3>
{totalCount > entries.length && (
<span className="text-[11px] text-muted-foreground">
{t("admin.audit.showing", {
shown: entries.length,
total: totalCount,
})}
</span>
)}
</div>
{isLoading ? (
<div
className="flex items-center gap-2 text-xs text-muted-foreground"
data-testid={`${testIdRoot}-loading`}
>
<Loader2 size={12} className="animate-spin" />
<span>{t("admin.audit.recent.loading")}</span>
</div>
) : entries.length === 0 ? (
<p
className="text-xs text-muted-foreground"
data-testid={`${testIdRoot}-empty`}
>
{t("admin.audit.recent.empty")}
</p>
) : (
<ul
className="space-y-1.5"
data-testid={`${testIdRoot}-list`}
>
{entries.map((entry) => {
const summary =
formatAuditSummary(entry, t, lang) ??
t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
});
return (
<li
key={entry.id}
className="text-xs text-foreground rounded-lg bg-white/60 border border-slate-100 px-2.5 py-1.5"
data-testid={`${testIdRoot}-row-${entry.id}`}
>
<div
className="leading-snug"
data-testid={`${testIdRoot}-summary-${entry.id}`}
>
{summary}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground mt-0.5">
<span className="truncate">
{actorLabel(entry.actor, lang)}
</span>
<span aria-hidden="true">·</span>
<span>{formatAuditTimestamp(entry.createdAt, lang)}</span>
</div>
</li>
);
})}
</ul>
)}
<button
type="button"
onClick={onViewFullHistory}
aria-label={t("admin.audit.recent.viewAllAria", { type: targetType })}
className="text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm"
data-testid={`${testIdRoot}-view-all`}
>
{t("admin.audit.recent.viewAll")}
</button>
</div>
);
}
// Parses targetType / targetId values out of the admin page URL hash so the
// audit log panel can deep-link to a pre-filtered view (e.g. when an admin
// clicks a dependency chip on a forced-delete row).