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:
riyadhafraa
2026-04-30 07:25:16 +00:00
parent d72c5cc851
commit 058cb8b817
8 changed files with 560 additions and 20 deletions
+20 -1
View File
@@ -36,6 +36,8 @@ type AuditFilters = {
forcedOnly: boolean;
fromDate: Date | null;
toDateExclusive: Date | null;
targetType: string;
targetId: number | null;
};
function parseFilters(req: Request, res: Response): AuditFilters | null {
@@ -45,6 +47,10 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
const forcedOnly = forcedOnlyRaw === "true" || forcedOnlyRaw === "1";
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
const targetType =
typeof req.query.targetType === "string" ? req.query.targetType.trim() : "";
const targetIdStr =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
let fromDate: Date | null = null;
let toDateExclusive: Date | null = null;
@@ -67,7 +73,16 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
res.status(400).json({ error: "'from' must be on or before 'to'" });
return null;
}
return { action, forcedOnly, fromDate, toDateExclusive };
let targetId: number | null = null;
if (targetIdStr) {
const parsed = Number(targetIdStr);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
res.status(400).json({ error: "Invalid 'targetId' (expected positive integer)" });
return null;
}
targetId = parsed;
}
return { action, forcedOnly, fromDate, toDateExclusive, targetType, targetId };
}
function buildWhere(filters: AuditFilters): SQL | undefined {
@@ -89,6 +104,10 @@ function buildWhere(filters: AuditFilters): SQL | undefined {
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
if (filters.toDateExclusive)
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
if (filters.targetType)
conditions.push(eq(auditLogsTable.targetType, filters.targetType));
if (filters.targetId != null)
conditions.push(eq(auditLogsTable.targetId, filters.targetId));
return conditions.length > 0 ? and(...conditions) : undefined;
}
@@ -0,0 +1,243 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
let adminId;
let adminUsername;
let adminCookie;
const insertedAuditIds = [];
const TEST_TAG = `target_filter_test_${STAMP}`;
// A handful of unique parent IDs so we can verify targetId filtering.
const PARENT_SERVICE_ID = 800100001;
const PARENT_SERVICE_ID_OTHER = 800100002;
const PARENT_GROUP_ID = 800200001;
const PARENT_APP_ID = 800300001;
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
async function insertAuditRow({ action, targetType, targetId, metadata }) {
const r = await pool.query(
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
[
adminId,
action,
targetType,
targetId,
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
return r.rows[0].id;
}
before(async () => {
adminUsername = `audit_target_admin_${STAMP}`;
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Audit Target Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// History for the parent service that will be force-deleted.
await insertAuditRow({
action: "service.create",
targetType: "service",
targetId: PARENT_SERVICE_ID,
metadata: { nameEn: "ParentSvc" },
});
await insertAuditRow({
action: "service.update",
targetType: "service",
targetId: PARENT_SERVICE_ID,
metadata: { changes: 2 },
});
await insertAuditRow({
action: "service.force_delete",
targetType: "service",
targetId: PARENT_SERVICE_ID,
metadata: { nameEn: "ParentSvc", orderCount: 7, messageCount: 3 },
});
// A different service so we can prove targetId narrows correctly.
await insertAuditRow({
action: "service.create",
targetType: "service",
targetId: PARENT_SERVICE_ID_OTHER,
metadata: { nameEn: "OtherSvc" },
});
// Group and app rows so we can prove targetType-only filtering works.
await insertAuditRow({
action: "group.create",
targetType: "group",
targetId: PARENT_GROUP_ID,
metadata: { name: "Grp" },
});
await insertAuditRow({
action: "group.force_delete",
targetType: "group",
targetId: PARENT_GROUP_ID,
metadata: { name: "Grp", memberCount: 4 },
});
await insertAuditRow({
action: "app.create",
targetType: "app",
targetId: PARENT_APP_ID,
metadata: { nameEn: "AppX" },
});
});
after(async () => {
if (insertedAuditIds.length) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
if (adminId !== undefined) {
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [
adminId,
]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
}
await pool.end();
});
function ownEntries(body) {
return body.entries.filter(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
e.metadata.testTag === TEST_TAG,
);
}
async function listWithParams(qs) {
const res = await fetch(`${API_BASE}/api/admin/audit-logs?${qs}`, {
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 200);
return res.json();
}
test("targetType filter narrows results to that target_type", async () => {
const body = await listWithParams("targetType=group&limit=200");
const ours = ownEntries(body);
assert.ok(ours.length >= 2, "expected at least the seeded group rows");
for (const e of ours) {
assert.equal(e.targetType, "group", `unexpected targetType ${e.targetType}`);
}
});
test("targetType=app excludes service and group rows", async () => {
const body = await listWithParams("targetType=app&limit=200");
const ours = ownEntries(body);
assert.ok(ours.some((e) => e.targetId === PARENT_APP_ID));
assert.ok(!ours.some((e) => e.targetType === "service"));
assert.ok(!ours.some((e) => e.targetType === "group"));
});
test("targetType + targetId returns only that single entity's history", async () => {
const body = await listWithParams(
`targetType=service&targetId=${PARENT_SERVICE_ID}&limit=200`,
);
const ours = ownEntries(body);
// 3 seeded rows for that service: create, update, force_delete.
assert.equal(ours.length, 3);
for (const e of ours) {
assert.equal(e.targetType, "service");
assert.equal(e.targetId, PARENT_SERVICE_ID);
}
const actions = ours.map((e) => e.action).sort();
assert.deepEqual(actions, [
"service.create",
"service.force_delete",
"service.update",
]);
});
test("targetType + targetId excludes other entities of the same type", async () => {
const body = await listWithParams(
`targetType=service&targetId=${PARENT_SERVICE_ID}&limit=200`,
);
const ours = ownEntries(body);
assert.ok(
!ours.some((e) => e.targetId === PARENT_SERVICE_ID_OTHER),
"other service rows must not appear",
);
});
test("invalid targetId returns 400", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?targetType=service&targetId=not-a-number`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error ?? "", /targetId/);
});
test("targetType combines with forcedOnly filter", async () => {
const body = await listWithParams(
"targetType=service&forcedOnly=true&limit=200",
);
const ours = ownEntries(body);
assert.ok(ours.some((e) => e.action === "service.force_delete"));
// Non-force rows must be excluded by forcedOnly.
assert.ok(!ours.some((e) => e.action === "service.create"));
assert.ok(!ours.some((e) => e.action === "service.update"));
});
test("CSV export honors targetType + targetId filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?targetType=service&targetId=${PARENT_SERVICE_ID}`,
{ headers: { Cookie: adminCookie, Accept: "text/csv" } },
);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
const text = await res.text();
const ourLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.ok(ourLines.length >= 3);
for (const line of ourLines) {
assert.match(
line,
new RegExp(`,service,${PARENT_SERVICE_ID},`),
`expected every CSV row to be the targeted service: ${line}`,
);
}
});
+7 -2
View File
@@ -558,7 +558,10 @@
"apply": "تطبيق",
"reset": "إعادة ضبط",
"forcedOnly": "عمليات الحذف القسري فقط",
"forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط."
"forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط.",
"targetType": "نوع الهدف: {{type}}",
"targetWithId": "الهدف: {{type}} #{{id}}",
"clearTargetFilter": "مسح فلتر الهدف"
},
"target": "الهدف: {{type}} #{{id}}",
"targetWithName": "الهدف: {{type}} #{{id}} · {{name}}",
@@ -649,7 +652,9 @@
"open_other": "{{count}} فتحة"
},
"dependencies": {
"label": "التبعيات المُزالة"
"label": "التبعيات المُزالة",
"chipAriaTargetType": "{{label}} — فتح سجل التدقيق مفلتراً حسب نوع الهدف \"{{type}}\"",
"chipAriaParent": "{{label}} — فتح سجل التدقيق لـ {{type}} #{{id}}"
},
"summary": {
"app": {
+7 -2
View File
@@ -543,7 +543,10 @@
"apply": "Apply",
"reset": "Reset",
"forcedOnly": "Forced deletions only",
"forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services."
"forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services.",
"targetType": "Target type: {{type}}",
"targetWithId": "Target: {{type}} #{{id}}",
"clearTargetFilter": "Clear target filter"
},
"target": "Target: {{type}} #{{id}}",
"targetWithName": "Target: {{type}} #{{id}} · {{name}}",
@@ -586,7 +589,9 @@
"open_other": "{{count}} opens"
},
"dependencies": {
"label": "Cleared dependencies"
"label": "Cleared dependencies",
"chipAriaTargetType": "{{label}} — open audit log filtered by target type \"{{type}}\"",
"chipAriaParent": "{{label}} — open audit log for {{type}} #{{id}}"
},
"summary": {
"app": {
+195 -15
View File
@@ -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>
)}
@@ -1097,6 +1097,21 @@ contains `force: true`. Overrides the `action` filter when set.
* End date (inclusive, YYYY-MM-DD UTC).
*/
to?: string;
/**
* Restrict results to entries whose `target_type` exactly matches
(e.g. "group", "app", "role", "user", "service", "settings").
Combines with `targetId` and the other filters.
*/
targetType?: string;
/**
* Restrict results to entries whose `target_id` exactly matches.
Typically used together with `targetType` to focus on the history
of a single entity.
* @minimum 1
*/
targetId?: number;
/**
* @minimum 1
* @maximum 200
@@ -1128,4 +1143,16 @@ Overrides the `action` filter when set.
* End date (inclusive, YYYY-MM-DD UTC).
*/
to?: string;
/**
* Restrict the export to entries whose `target_type` exactly matches.
Combines with `targetId` and the other filters.
*/
targetType?: string;
/**
* Restrict the export to entries whose `target_id` exactly matches.
* @minimum 1
*/
targetId?: number;
};
+35
View File
@@ -2035,6 +2035,25 @@ paths:
type: string
format: date
description: End date (inclusive, YYYY-MM-DD UTC).
- in: query
name: targetType
required: false
schema:
type: string
description: |
Restrict results to entries whose `target_type` exactly matches
(e.g. "group", "app", "role", "user", "service", "settings").
Combines with `targetId` and the other filters.
- in: query
name: targetId
required: false
schema:
type: integer
minimum: 1
description: |
Restrict results to entries whose `target_id` exactly matches.
Typically used together with `targetType` to focus on the history
of a single entity.
- in: query
name: limit
required: false
@@ -2106,6 +2125,22 @@ paths:
type: string
format: date
description: End date (inclusive, YYYY-MM-DD UTC).
- in: query
name: targetType
required: false
schema:
type: string
description: |
Restrict the export to entries whose `target_type` exactly matches.
Combines with `targetId` and the other filters.
- in: query
name: targetId
required: false
schema:
type: integer
minimum: 1
description: |
Restrict the export to entries whose `target_id` exactly matches.
responses:
"200":
description: CSV file download
+26
View File
@@ -2434,6 +2434,19 @@ export const ListAuditLogsQueryParams = zod.object({
.optional()
.describe("Start date (inclusive, YYYY-MM-DD UTC)."),
to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."),
targetType: zod.coerce
.string()
.optional()
.describe(
'Restrict results to entries whose `target_type` exactly matches\n(e.g. \"group\", \"app\", \"role\", \"user\", \"service\", \"settings\").\nCombines with `targetId` and the other filters.\n',
),
targetId: zod.coerce
.number()
.min(1)
.optional()
.describe(
"Restrict results to entries whose `target_id` exactly matches.\nTypically used together with `targetType` to focus on the history\nof a single entity.\n",
),
limit: zod.coerce
.number()
.min(1)
@@ -2504,4 +2517,17 @@ export const ExportAuditLogsCsvQueryParams = zod.object({
.optional()
.describe("Start date (inclusive, YYYY-MM-DD UTC)."),
to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."),
targetType: zod.coerce
.string()
.optional()
.describe(
"Restrict the export to entries whose `target_type` exactly matches.\nCombines with `targetId` and the other filters.\n",
),
targetId: zod.coerce
.number()
.min(1)
.optional()
.describe(
"Restrict the export to entries whose `target_id` exactly matches.\n",
),
});