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).
Replit-Task-Id: c03451f9-a6bb-4dd4-b082-f34ce3b6001d
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -761,6 +761,13 @@
|
||||
"chipAriaTargetType": "{{label}} — فتح سجل التدقيق مفلتراً حسب نوع الهدف \"{{type}}\"",
|
||||
"chipAriaParent": "{{label}} — فتح سجل التدقيق لـ {{type}} #{{id}}"
|
||||
},
|
||||
"recent": {
|
||||
"title": "آخر النشاطات",
|
||||
"loading": "جارٍ تحميل آخر النشاطات…",
|
||||
"empty": "لا توجد نشاطات حديثة لهذا السجل بعد.",
|
||||
"viewAll": "عرض السجل الكامل",
|
||||
"viewAllAria": "فتح سجل التدقيق مفلتراً على هذا الـ {{type}}"
|
||||
},
|
||||
"summary": {
|
||||
"app": {
|
||||
"create": "تم إنشاء التطبيق '{{name}}'",
|
||||
|
||||
@@ -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}}'",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user