Make forced-delete dependency chips clickable to pivot the audit log
Task #134: Admins investigating a forced deletion can now click any dependency chip on a force_delete row to jump to a pre-filtered audit log view of the related history. Backend (artifacts/api-server, lib/api-spec): - Added targetType + targetId query params to GET /api/admin/audit-logs and its CSV export. targetId is validated as a positive integer (400 on bad input). Codegen regenerated for the React API client. Frontend (artifacts/tx-os/src/pages/admin.tsx): - Dependency chips on forced-delete rows are now real <button> elements with aria-labels and keyboard focus. Non-deletion rows are unchanged. - Chip → pivot mapping: groupCount→targetType=group, memberCount→targetType=user, appCount→targetType=app, roleCount→targetType=role; cascade chips (orderCount, messageCount, noteCount, etc.) pivot to the parent (targetType+targetId). - Active filter is reflected in the URL hash (deep-linkable + reload safe) and shown as a dismissible pill in the panel; the pill includes a Clear button. Switching audit sub-section drops the filter. - Section sync logic preserves hash params and uses a one-shot skipNextSectionSync ref so initial deep-linked hashes aren't clobbered. - New i18n keys in en.json and ar.json for filter labels and chip aria-labels. Tests: - New backend tests in artifacts/api-server/tests/audit-logs-target-filter.test.mjs cover targetType narrowing, targetType+targetId narrowing, invalid targetId rejection, combination with forcedOnly, and CSV export honoring the new filters (7 tests, all passing). - Verified end-to-end via the browser testing skill: chip click, filter pill, clear, deep-link reload all behave correctly. Pre-existing unrelated failures (not touched): two PDF archive tests in executive-meetings.test.mjs and the matching typecheck errors in executive-meetings.ts. Replit-Task-Id: 46f972ef-2874-4fc3-95c5-53d0ff0732e9
This commit is contained in:
@@ -580,9 +580,28 @@ export default function AdminPage() {
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// Skip the very first render so we don't clobber a deep-linked hash like
|
||||
// `#section=audit-log&targetType=group&targetId=42` before the mount-time
|
||||
// parser above has had a chance to apply it.
|
||||
const skipNextSectionSync = useRef<boolean>(true);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const newHash = `#section=${section}`;
|
||||
if (skipNextSectionSync.current) {
|
||||
skipNextSectionSync.current = false;
|
||||
return;
|
||||
}
|
||||
const raw = window.location.hash.replace(/^#/, "");
|
||||
const params = new URLSearchParams(raw);
|
||||
const previousSection = params.get("section");
|
||||
params.set("section", section);
|
||||
// Section-scoped params (the audit log's targetType / targetId deep
|
||||
// links) only make sense within their owning section. Switching to a
|
||||
// different section drops them so the next visit starts fresh.
|
||||
if (previousSection !== section) {
|
||||
params.delete("targetType");
|
||||
params.delete("targetId");
|
||||
}
|
||||
const newHash = `#${params.toString()}`;
|
||||
if (window.location.hash !== newHash) {
|
||||
window.history.replaceState(null, "", newHash);
|
||||
}
|
||||
@@ -4827,6 +4846,23 @@ const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
|
||||
openCount: "open",
|
||||
};
|
||||
|
||||
// When a dependency chip key maps to a known audit-log target_type, clicking
|
||||
// the chip pivots to "audit log filtered by that target_type". Other chip
|
||||
// keys (orders, messages, notes, conversations, opens) have no audit_logs
|
||||
// rows of their own, so they pivot to the parent entity's audit history
|
||||
// instead (target_type + target_id of the deletion row).
|
||||
const DEPENDENCY_CHIP_TARGET_TYPES: Record<string, string> = {
|
||||
groupCount: "group",
|
||||
memberCount: "user",
|
||||
appCount: "app",
|
||||
roleCount: "role",
|
||||
};
|
||||
|
||||
type AuditTargetPivot = {
|
||||
targetType: string;
|
||||
targetId: number | null;
|
||||
};
|
||||
|
||||
function isForceDeleteEntry(entry: AuditLogEntry): boolean {
|
||||
const action = entry.action;
|
||||
if (action.endsWith(".force_delete")) return true;
|
||||
@@ -4861,18 +4897,35 @@ function forceDeletedEntityName(
|
||||
function dependencyChips(
|
||||
entry: AuditLogEntry,
|
||||
t: AuditTFunction,
|
||||
): Array<{ key: string; label: string }> {
|
||||
): Array<{ key: string; label: string; pivot: AuditTargetPivot }> {
|
||||
const meta = asRecord(entry.metadata);
|
||||
const chips: Array<{ key: string; label: string }> = [];
|
||||
const chips: Array<{ key: string; label: string; pivot: AuditTargetPivot }> =
|
||||
[];
|
||||
for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) {
|
||||
const value = asNumber(meta[metaKey]);
|
||||
if (value == null || value <= 0) continue;
|
||||
chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value) });
|
||||
const mappedTargetType = DEPENDENCY_CHIP_TARGET_TYPES[metaKey];
|
||||
const pivot: AuditTargetPivot = mappedTargetType
|
||||
? { targetType: mappedTargetType, targetId: null }
|
||||
: { targetType: entry.targetType, targetId: entry.targetId ?? null };
|
||||
chips.push({
|
||||
key: metaKey,
|
||||
label: unitLabel(t, unitRoot, value),
|
||||
pivot,
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
||||
function AuditLogRow({
|
||||
entry,
|
||||
lang,
|
||||
onPivotToTarget,
|
||||
}: {
|
||||
entry: AuditLogEntry;
|
||||
lang: string;
|
||||
onPivotToTarget: (pivot: AuditTargetPivot) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasMetadata =
|
||||
@@ -4961,15 +5014,31 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
||||
data-testid={`audit-deps-${entry.id}`}
|
||||
aria-label={t("admin.audit.dependencies.label")}
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
className="inline-block text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100"
|
||||
data-testid={`audit-dep-${entry.id}-${chip.key}`}
|
||||
>
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
{chips.map((chip) => {
|
||||
const ariaLabel = chip.pivot.targetId != null
|
||||
? t("admin.audit.dependencies.chipAriaParent", {
|
||||
label: chip.label,
|
||||
type: entry.targetType,
|
||||
id: chip.pivot.targetId,
|
||||
})
|
||||
: t("admin.audit.dependencies.chipAriaTargetType", {
|
||||
label: chip.label,
|
||||
type: chip.pivot.targetType,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={chip.key}
|
||||
onClick={() => onPivotToTarget(chip.pivot)}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className="inline-flex items-center text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100 hover:bg-rose-100 hover:border-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 transition-colors cursor-pointer"
|
||||
data-testid={`audit-dep-${entry.id}-${chip.key}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -5000,6 +5069,51 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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).
|
||||
function parseAuditHashTarget(): AuditTargetPivot {
|
||||
if (typeof window === "undefined") return { targetType: "", targetId: null };
|
||||
const raw = window.location.hash.replace(/^#/, "");
|
||||
const params = new URLSearchParams(raw);
|
||||
const targetType = params.get("targetType")?.trim() ?? "";
|
||||
const targetIdRaw = params.get("targetId")?.trim();
|
||||
let targetId: number | null = null;
|
||||
if (targetIdRaw) {
|
||||
const n = Number(targetIdRaw);
|
||||
if (Number.isFinite(n) && Number.isInteger(n) && n >= 1) {
|
||||
targetId = n;
|
||||
}
|
||||
}
|
||||
return { targetType, targetId };
|
||||
}
|
||||
|
||||
// Updates the admin URL hash (which carries `section=audit-log`) so the
|
||||
// active targetType/targetId filters survive reload and back/forward nav.
|
||||
// Uses replaceState to avoid polluting browser history with every tweak.
|
||||
function syncAuditHashTarget(pivot: AuditTargetPivot) {
|
||||
if (typeof window === "undefined") return;
|
||||
const raw = window.location.hash.replace(/^#/, "");
|
||||
const params = new URLSearchParams(raw);
|
||||
if (pivot.targetType) {
|
||||
params.set("targetType", pivot.targetType);
|
||||
} else {
|
||||
params.delete("targetType");
|
||||
}
|
||||
if (pivot.targetId != null) {
|
||||
params.set("targetId", String(pivot.targetId));
|
||||
} else {
|
||||
params.delete("targetId");
|
||||
}
|
||||
if (!params.has("section")) {
|
||||
params.set("section", "audit-log");
|
||||
}
|
||||
const newHash = `#${params.toString()}`;
|
||||
if (window.location.hash !== newHash) {
|
||||
window.history.replaceState(null, "", newHash);
|
||||
}
|
||||
}
|
||||
|
||||
function AuditLogPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -5012,6 +5126,15 @@ function AuditLogPanel() {
|
||||
const [pageLimit, setPageLimit] = useState<number>(50);
|
||||
const [exporting, setExporting] = useState<boolean>(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
// Deep-link target filters. Initialised from the URL hash so a user who
|
||||
// pastes /admin#section=audit-log&targetType=group lands directly on a
|
||||
// pre-filtered view. Updated by chip clicks and the explicit clear button.
|
||||
const [targetTypeFilter, setTargetTypeFilter] = useState<string>(
|
||||
() => parseAuditHashTarget().targetType,
|
||||
);
|
||||
const [targetIdFilter, setTargetIdFilter] = useState<number | null>(
|
||||
() => parseAuditHashTarget().targetId,
|
||||
);
|
||||
|
||||
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
|
||||
const toValid = !appliedTo || DATE_RE.test(appliedTo);
|
||||
@@ -5029,6 +5152,8 @@ function AuditLogPanel() {
|
||||
: {}),
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
|
||||
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
|
||||
};
|
||||
|
||||
const queryKey = getListAuditLogsQueryKey(params);
|
||||
@@ -5060,6 +5185,9 @@ function AuditLogPanel() {
|
||||
setAppliedFrom("");
|
||||
setAppliedTo("");
|
||||
setPageLimit(50);
|
||||
setTargetTypeFilter("");
|
||||
setTargetIdFilter(null);
|
||||
syncAuditHashTarget({ targetType: "", targetId: null });
|
||||
};
|
||||
|
||||
const toggleForcedOnly = () => {
|
||||
@@ -5071,6 +5199,24 @@ function AuditLogPanel() {
|
||||
});
|
||||
};
|
||||
|
||||
// Chip click on a forced-delete row: pre-filter the panel to the chosen
|
||||
// target_type (and optionally target_id). Resets pagination so the new
|
||||
// result set starts fresh, and updates the URL hash so the filter
|
||||
// survives reload / back-forward navigation.
|
||||
const pivotToTarget = (pivot: AuditTargetPivot) => {
|
||||
setTargetTypeFilter(pivot.targetType);
|
||||
setTargetIdFilter(pivot.targetId);
|
||||
setPageLimit(50);
|
||||
syncAuditHashTarget(pivot);
|
||||
};
|
||||
|
||||
const clearTargetFilter = () => {
|
||||
setTargetTypeFilter("");
|
||||
setTargetIdFilter(null);
|
||||
setPageLimit(50);
|
||||
syncAuditHashTarget({ targetType: "", targetId: null });
|
||||
};
|
||||
|
||||
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
@@ -5086,6 +5232,8 @@ function AuditLogPanel() {
|
||||
: {}),
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
|
||||
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
@@ -5116,6 +5264,15 @@ function AuditLogPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeTargetPillLabel = targetTypeFilter
|
||||
? targetIdFilter != null
|
||||
? t("admin.audit.filters.targetWithId", {
|
||||
type: targetTypeFilter,
|
||||
id: targetIdFilter,
|
||||
})
|
||||
: t("admin.audit.filters.targetType", { type: targetTypeFilter })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="audit-log-panel">
|
||||
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
||||
@@ -5136,6 +5293,24 @@ function AuditLogPanel() {
|
||||
<Trash2 size={12} />
|
||||
{t("admin.audit.filters.forcedOnly")}
|
||||
</button>
|
||||
{activeTargetPillLabel && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border bg-emerald-100 text-emerald-800 border-emerald-200"
|
||||
data-testid="audit-target-filter-pill"
|
||||
>
|
||||
<span className="truncate">{activeTargetPillLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearTargetFilter}
|
||||
aria-label={t("admin.audit.filters.clearTargetFilter")}
|
||||
title={t("admin.audit.filters.clearTargetFilter")}
|
||||
className="ms-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-emerald-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400"
|
||||
data-testid="audit-clear-target-filter"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
@@ -5245,7 +5420,12 @@ function AuditLogPanel() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<AuditLogRow key={entry.id} entry={entry} lang={lang} />
|
||||
<AuditLogRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
lang={lang}
|
||||
onPivotToTarget={pivotToTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user