Git commit prior to merge
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter, type Request, type Response } from "express";
|
import { Router, type IRouter, type Request, type Response } from "express";
|
||||||
import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
|
import { and, desc, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm";
|
||||||
import { db } from "@workspace/db";
|
import { db } from "@workspace/db";
|
||||||
import { auditLogsTable, usersTable } from "@workspace/db";
|
import { auditLogsTable, usersTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middlewares/auth";
|
import { requireAdmin } from "../middlewares/auth";
|
||||||
@@ -9,6 +9,22 @@ const router: IRouter = Router();
|
|||||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
const EXPORT_ROW_CAP = 50_000;
|
const EXPORT_ROW_CAP = 50_000;
|
||||||
|
|
||||||
|
// Actions that explicitly mark a forced deletion. Both the dedicated
|
||||||
|
// `*.force_delete` action names and the legacy `*.delete` rows whose metadata
|
||||||
|
// carries `force: true` count as forced deletions for the audit-log filter.
|
||||||
|
const FORCE_DELETE_ACTIONS = [
|
||||||
|
"group.force_delete",
|
||||||
|
"user.force_delete",
|
||||||
|
"app.force_delete",
|
||||||
|
"service.force_delete",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG = [
|
||||||
|
"group.delete",
|
||||||
|
"user.delete",
|
||||||
|
"app.delete",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function parseUtcDate(s: string): Date | null {
|
function parseUtcDate(s: string): Date | null {
|
||||||
if (!DATE_RE.test(s)) return null;
|
if (!DATE_RE.test(s)) return null;
|
||||||
const d = new Date(`${s}T00:00:00.000Z`);
|
const d = new Date(`${s}T00:00:00.000Z`);
|
||||||
@@ -17,12 +33,16 @@ function parseUtcDate(s: string): Date | null {
|
|||||||
|
|
||||||
type AuditFilters = {
|
type AuditFilters = {
|
||||||
action: string;
|
action: string;
|
||||||
|
forcedOnly: boolean;
|
||||||
fromDate: Date | null;
|
fromDate: Date | null;
|
||||||
toDateExclusive: Date | null;
|
toDateExclusive: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseFilters(req: Request, res: Response): AuditFilters | null {
|
function parseFilters(req: Request, res: Response): AuditFilters | null {
|
||||||
const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
|
const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
|
||||||
|
const forcedOnlyRaw =
|
||||||
|
typeof req.query.forcedOnly === "string" ? req.query.forcedOnly.trim() : "";
|
||||||
|
const forcedOnly = forcedOnlyRaw === "true" || forcedOnlyRaw === "1";
|
||||||
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
||||||
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
|
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
|
||||||
|
|
||||||
@@ -47,12 +67,25 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
|
|||||||
res.status(400).json({ error: "'from' must be on or before 'to'" });
|
res.status(400).json({ error: "'from' must be on or before 'to'" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { action, fromDate, toDateExclusive };
|
return { action, forcedOnly, fromDate, toDateExclusive };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWhere(filters: AuditFilters): SQL | undefined {
|
function buildWhere(filters: AuditFilters): SQL | undefined {
|
||||||
const conditions: SQL[] = [];
|
const conditions: SQL[] = [];
|
||||||
if (filters.action) conditions.push(eq(auditLogsTable.action, filters.action));
|
if (filters.forcedOnly) {
|
||||||
|
// Either an explicit *.force_delete action, or a legacy *.delete row whose
|
||||||
|
// metadata records that the deletion was forced (force: true).
|
||||||
|
const forcedClause = or(
|
||||||
|
inArray(auditLogsTable.action, [...FORCE_DELETE_ACTIONS]),
|
||||||
|
and(
|
||||||
|
inArray(auditLogsTable.action, [...SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG]),
|
||||||
|
sql`${auditLogsTable.metadata} @> '{"force": true}'::jsonb`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (forcedClause) conditions.push(forcedClause);
|
||||||
|
} else if (filters.action) {
|
||||||
|
conditions.push(eq(auditLogsTable.action, filters.action));
|
||||||
|
}
|
||||||
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
|
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
|
||||||
if (filters.toDateExclusive)
|
if (filters.toDateExclusive)
|
||||||
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
|
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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 = [];
|
||||||
|
// Tag every seeded audit row's metadata with this token so the test can
|
||||||
|
// reliably identify its own rows even though `target_id` is a plain integer.
|
||||||
|
const TEST_TAG = `forced_only_test_${STAMP}`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't care about the actual integer target_id values — only whether the
|
||||||
|
// row matches the filter. Use an arbitrary unique-per-row int.
|
||||||
|
let nextTargetId = 900000000;
|
||||||
|
const seedRow = (action, targetType, metadata) =>
|
||||||
|
insertAuditRow({
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId: nextTargetId++,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
adminUsername = `audit_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 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);
|
||||||
|
|
||||||
|
// Each of the four explicit *.force_delete actions.
|
||||||
|
await seedRow("group.force_delete", "group", { name: "GrpA", memberCount: 3 });
|
||||||
|
await seedRow("user.force_delete", "user", { username: "UsrA", noteCount: 2 });
|
||||||
|
await seedRow("app.force_delete", "app", { nameEn: "AppA", groupCount: 1 });
|
||||||
|
await seedRow("service.force_delete", "service", { nameEn: "SvcA", orderCount: 5 });
|
||||||
|
|
||||||
|
// Legacy soft-delete rows with metadata.force=true.
|
||||||
|
await seedRow("group.delete", "group", { name: "GrpB", force: true, memberCount: 1 });
|
||||||
|
await seedRow("user.delete", "user", { username: "UsrB", force: true, messageCount: 4 });
|
||||||
|
await seedRow("app.delete", "app", { nameEn: "AppB", force: true, groupCount: 2 });
|
||||||
|
|
||||||
|
// Non-forced rows that must NOT match the forcedOnly filter.
|
||||||
|
await seedRow("group.delete", "group", { name: "GrpC", force: false });
|
||||||
|
await seedRow("user.delete", "user", { username: "UsrC" });
|
||||||
|
await seedRow("app.create", "app", { nameEn: "AppC" });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 listForcedOnly() {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/audit-logs?forcedOnly=true&limit=200`,
|
||||||
|
{ headers: { Cookie: adminCookie } },
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("forcedOnly=true returns explicit *.force_delete rows", async () => {
|
||||||
|
const body = await listForcedOnly();
|
||||||
|
const ours = ownEntries(body);
|
||||||
|
const actions = ours.map((e) => e.action);
|
||||||
|
assert.ok(actions.includes("group.force_delete"));
|
||||||
|
assert.ok(actions.includes("user.force_delete"));
|
||||||
|
assert.ok(actions.includes("app.force_delete"));
|
||||||
|
assert.ok(actions.includes("service.force_delete"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forcedOnly=true includes legacy *.delete rows with metadata.force=true", async () => {
|
||||||
|
const body = await listForcedOnly();
|
||||||
|
const ours = ownEntries(body);
|
||||||
|
const legacyForced = ours.filter(
|
||||||
|
(e) =>
|
||||||
|
(e.action === "group.delete" ||
|
||||||
|
e.action === "user.delete" ||
|
||||||
|
e.action === "app.delete") &&
|
||||||
|
e.metadata?.force === true,
|
||||||
|
);
|
||||||
|
assert.equal(legacyForced.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forcedOnly=true excludes non-force rows", async () => {
|
||||||
|
const body = await listForcedOnly();
|
||||||
|
const ours = ownEntries(body);
|
||||||
|
for (const e of ours) {
|
||||||
|
const isExplicit = e.action.endsWith(".force_delete");
|
||||||
|
const isLegacyForced =
|
||||||
|
(e.action === "group.delete" ||
|
||||||
|
e.action === "user.delete" ||
|
||||||
|
e.action === "app.delete") &&
|
||||||
|
e.metadata?.force === true;
|
||||||
|
assert.ok(
|
||||||
|
isExplicit || isLegacyForced,
|
||||||
|
`unexpected non-forced row in result: ${e.action} (force=${e.metadata?.force})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forcedOnly overrides action filter when both are provided", async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/audit-logs?forcedOnly=true&action=app.create&limit=200`,
|
||||||
|
{ headers: { Cookie: adminCookie } },
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
const ours = ownEntries(body);
|
||||||
|
// No app.create rows should appear because forcedOnly takes precedence.
|
||||||
|
assert.ok(!ours.some((e) => e.action === "app.create"));
|
||||||
|
// But the seeded force-delete rows should still appear.
|
||||||
|
assert.ok(ours.some((e) => e.action === "group.force_delete"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forcedOnly=false (default) ignores the force filter", async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/audit-logs?action=app.create&limit=200`,
|
||||||
|
{ headers: { Cookie: adminCookie } },
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
const ours = ownEntries(body);
|
||||||
|
assert.ok(ours.some((e) => e.action === "app.create"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSV export honors forcedOnly", async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/audit-logs/export?forcedOnly=true`,
|
||||||
|
{ 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();
|
||||||
|
// The seeded force-delete actions should appear; a non-forced create must not
|
||||||
|
// appear in OUR rows. Use the testTag to narrow to our rows.
|
||||||
|
const ourLines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.includes(TEST_TAG));
|
||||||
|
assert.ok(ourLines.length > 0, "expected at least one of our rows in CSV");
|
||||||
|
assert.ok(ourLines.some((l) => l.startsWith("group.force_delete,")));
|
||||||
|
assert.ok(ourLines.some((l) => l.startsWith("user.delete,")));
|
||||||
|
assert.ok(!ourLines.some((l) => l.startsWith("app.create,")));
|
||||||
|
});
|
||||||
@@ -483,7 +483,9 @@
|
|||||||
"from": "من",
|
"from": "من",
|
||||||
"to": "إلى",
|
"to": "إلى",
|
||||||
"apply": "تطبيق",
|
"apply": "تطبيق",
|
||||||
"reset": "إعادة ضبط"
|
"reset": "إعادة ضبط",
|
||||||
|
"forcedOnly": "عمليات الحذف القسري فقط",
|
||||||
|
"forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط."
|
||||||
},
|
},
|
||||||
"target": "الهدف: {{type}} #{{id}}",
|
"target": "الهدف: {{type}} #{{id}}",
|
||||||
"showing": "عرض {{shown}} من أصل {{total}}",
|
"showing": "عرض {{shown}} من أصل {{total}}",
|
||||||
@@ -564,7 +566,16 @@
|
|||||||
"message_two": "رسالتان",
|
"message_two": "رسالتان",
|
||||||
"message_few": "{{count}} رسائل",
|
"message_few": "{{count}} رسائل",
|
||||||
"message_many": "{{count}} رسالةً",
|
"message_many": "{{count}} رسالةً",
|
||||||
"message_other": "{{count}} رسالة"
|
"message_other": "{{count}} رسالة",
|
||||||
|
"open_zero": "بدون فتحات",
|
||||||
|
"open_one": "فتحة واحدة",
|
||||||
|
"open_two": "فتحتان",
|
||||||
|
"open_few": "{{count}} فتحات",
|
||||||
|
"open_many": "{{count}} فتحةً",
|
||||||
|
"open_other": "{{count}} فتحة"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"label": "التبعيات المُزالة"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
@@ -480,7 +480,9 @@
|
|||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"reset": "Reset"
|
"reset": "Reset",
|
||||||
|
"forcedOnly": "Forced deletions only",
|
||||||
|
"forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services."
|
||||||
},
|
},
|
||||||
"target": "Target: {{type}} #{{id}}",
|
"target": "Target: {{type}} #{{id}}",
|
||||||
"showing": "Showing {{shown}} of {{total}}",
|
"showing": "Showing {{shown}} of {{total}}",
|
||||||
@@ -517,7 +519,12 @@
|
|||||||
"conversation_one": "{{count}} conversation",
|
"conversation_one": "{{count}} conversation",
|
||||||
"conversation_other": "{{count}} conversations",
|
"conversation_other": "{{count}} conversations",
|
||||||
"message_one": "{{count}} message",
|
"message_one": "{{count}} message",
|
||||||
"message_other": "{{count}} messages"
|
"message_other": "{{count}} messages",
|
||||||
|
"open_one": "{{count}} open",
|
||||||
|
"open_other": "{{count}} opens"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"label": "Cleared dependencies"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
@@ -3959,6 +3959,44 @@ function formatAuditSummary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map of well-known dependency-count metadata keys → unit i18n root used by
|
||||||
|
// `unitLabel`. Keys not in this map are skipped from the inline chip strip.
|
||||||
|
const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
|
||||||
|
orderCount: "order",
|
||||||
|
messageCount: "message",
|
||||||
|
noteCount: "note",
|
||||||
|
conversationCount: "conversation",
|
||||||
|
groupCount: "group",
|
||||||
|
memberCount: "member",
|
||||||
|
appCount: "app",
|
||||||
|
roleCount: "role",
|
||||||
|
openCount: "open",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isForceDeleteEntry(entry: AuditLogEntry): boolean {
|
||||||
|
const action = entry.action;
|
||||||
|
if (action.endsWith(".force_delete")) return true;
|
||||||
|
if (action === "user.delete" || action === "app.delete" || action === "group.delete") {
|
||||||
|
const meta = asRecord(entry.metadata);
|
||||||
|
return meta.force === true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dependencyChips(
|
||||||
|
entry: AuditLogEntry,
|
||||||
|
t: AuditTFunction,
|
||||||
|
): Array<{ key: string; label: string }> {
|
||||||
|
const meta = asRecord(entry.metadata);
|
||||||
|
const chips: Array<{ key: string; label: string }> = [];
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -3967,6 +4005,8 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
|||||||
const actor = entry.actor;
|
const actor = entry.actor;
|
||||||
const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase();
|
const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase();
|
||||||
const summary = formatAuditSummary(entry, t, lang);
|
const summary = formatAuditSummary(entry, t, lang);
|
||||||
|
const isForced = isForceDeleteEntry(entry);
|
||||||
|
const chips = isForced ? dependencyChips(entry, t) : [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="glass-panel rounded-2xl p-3 space-y-2"
|
className="glass-panel rounded-2xl p-3 space-y-2"
|
||||||
@@ -4006,7 +4046,12 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||||
<span
|
<span
|
||||||
className="inline-block text-[11px] font-mono px-2 py-0.5 rounded-md bg-amber-100 text-amber-800"
|
className={cn(
|
||||||
|
"inline-block text-[11px] font-mono px-2 py-0.5 rounded-md",
|
||||||
|
isForced
|
||||||
|
? "bg-rose-100 text-rose-800"
|
||||||
|
: "bg-amber-100 text-amber-800",
|
||||||
|
)}
|
||||||
data-testid={`audit-action-${entry.id}`}
|
data-testid={`audit-action-${entry.id}`}
|
||||||
>
|
>
|
||||||
{entry.action}
|
{entry.action}
|
||||||
@@ -4015,6 +4060,23 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
|
|||||||
{formatAuditTimestamp(entry.createdAt, lang)}
|
{formatAuditTimestamp(entry.createdAt, lang)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{chips.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center gap-1.5 mt-1.5"
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasMetadata && (
|
{hasMetadata && (
|
||||||
<button
|
<button
|
||||||
@@ -4047,6 +4109,7 @@ function AuditLogPanel() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const [actionFilter, setActionFilter] = useState<string>("");
|
const [actionFilter, setActionFilter] = useState<string>("");
|
||||||
|
const [forcedOnly, setForcedOnly] = useState<boolean>(false);
|
||||||
const [fromInput, setFromInput] = useState<string>("");
|
const [fromInput, setFromInput] = useState<string>("");
|
||||||
const [toInput, setToInput] = useState<string>("");
|
const [toInput, setToInput] = useState<string>("");
|
||||||
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
||||||
@@ -4064,7 +4127,11 @@ function AuditLogPanel() {
|
|||||||
const params: ListAuditLogsParams = {
|
const params: ListAuditLogsParams = {
|
||||||
limit: pageLimit,
|
limit: pageLimit,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
...(actionFilter ? { action: actionFilter } : {}),
|
...(forcedOnly
|
||||||
|
? { forcedOnly: true }
|
||||||
|
: actionFilter
|
||||||
|
? { action: actionFilter }
|
||||||
|
: {}),
|
||||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||||
...(appliedTo ? { to: appliedTo } : {}),
|
...(appliedTo ? { to: appliedTo } : {}),
|
||||||
};
|
};
|
||||||
@@ -4092,6 +4159,7 @@ function AuditLogPanel() {
|
|||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setActionFilter("");
|
setActionFilter("");
|
||||||
|
setForcedOnly(false);
|
||||||
setFromInput("");
|
setFromInput("");
|
||||||
setToInput("");
|
setToInput("");
|
||||||
setAppliedFrom("");
|
setAppliedFrom("");
|
||||||
@@ -4099,6 +4167,15 @@ function AuditLogPanel() {
|
|||||||
setPageLimit(50);
|
setPageLimit(50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleForcedOnly = () => {
|
||||||
|
setForcedOnly((v) => {
|
||||||
|
const next = !v;
|
||||||
|
if (next) setActionFilter("");
|
||||||
|
setPageLimit(50);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
|
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
|
||||||
|
|
||||||
const handleExportCsv = async () => {
|
const handleExportCsv = async () => {
|
||||||
@@ -4107,7 +4184,11 @@ function AuditLogPanel() {
|
|||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const url = getExportAuditLogsCsvUrl({
|
const url = getExportAuditLogsCsvUrl({
|
||||||
...(actionFilter ? { action: actionFilter } : {}),
|
...(forcedOnly
|
||||||
|
? { forcedOnly: true }
|
||||||
|
: actionFilter
|
||||||
|
? { action: actionFilter }
|
||||||
|
: {}),
|
||||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||||
...(appliedTo ? { to: appliedTo } : {}),
|
...(appliedTo ? { to: appliedTo } : {}),
|
||||||
});
|
});
|
||||||
@@ -4143,13 +4224,35 @@ function AuditLogPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3" data-testid="audit-log-panel">
|
<div className="space-y-3" data-testid="audit-log-panel">
|
||||||
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleForcedOnly}
|
||||||
|
aria-pressed={forcedOnly}
|
||||||
|
title={t("admin.audit.filters.forcedOnlyHint")}
|
||||||
|
data-testid="audit-forced-only-chip"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-colors",
|
||||||
|
forcedOnly
|
||||||
|
? "bg-rose-100 text-rose-800 border-rose-200"
|
||||||
|
: "bg-white/70 text-foreground border-slate-200 hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
{t("admin.audit.filters.forcedOnly")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
|
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">{t("admin.audit.filters.action")}</Label>
|
<Label className="text-xs">{t("admin.audit.filters.action")}</Label>
|
||||||
<select
|
<select
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => setActionFilter(e.target.value)}
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
|
disabled={forcedOnly}
|
||||||
|
className={cn(
|
||||||
|
"bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full",
|
||||||
|
forcedOnly && "opacity-60 cursor-not-allowed",
|
||||||
|
)}
|
||||||
data-testid="audit-action-filter"
|
data-testid="audit-action-filter"
|
||||||
>
|
>
|
||||||
<option value="">{t("admin.audit.filters.allActions")}</option>
|
<option value="">{t("admin.audit.filters.allActions")}</option>
|
||||||
|
|||||||
@@ -1018,6 +1018,15 @@ export type ListAuditLogsParams = {
|
|||||||
* Exact action name to filter by (e.g. "group.force_delete").
|
* Exact action name to filter by (e.g. "group.force_delete").
|
||||||
*/
|
*/
|
||||||
action?: string;
|
action?: string;
|
||||||
|
/**
|
||||||
|
* When true, restrict results to forced deletions only — i.e. rows
|
||||||
|
with action `group.force_delete`, `user.force_delete`,
|
||||||
|
`app.force_delete`, `service.force_delete`, or any
|
||||||
|
`group.delete`/`user.delete`/`app.delete` row whose metadata
|
||||||
|
contains `force: true`. Overrides the `action` filter when set.
|
||||||
|
|
||||||
|
*/
|
||||||
|
forcedOnly?: boolean;
|
||||||
/**
|
/**
|
||||||
* Start date (inclusive, YYYY-MM-DD UTC).
|
* Start date (inclusive, YYYY-MM-DD UTC).
|
||||||
*/
|
*/
|
||||||
@@ -1042,6 +1051,13 @@ export type ExportAuditLogsCsvParams = {
|
|||||||
* Exact action name to filter by (e.g. "group.force_delete").
|
* Exact action name to filter by (e.g. "group.force_delete").
|
||||||
*/
|
*/
|
||||||
action?: string;
|
action?: string;
|
||||||
|
/**
|
||||||
|
* When true, restrict the export to forced deletions only.
|
||||||
|
See the list endpoint for the exact set of matching rows.
|
||||||
|
Overrides the `action` filter when set.
|
||||||
|
|
||||||
|
*/
|
||||||
|
forcedOnly?: boolean;
|
||||||
/**
|
/**
|
||||||
* Start date (inclusive, YYYY-MM-DD UTC).
|
* Start date (inclusive, YYYY-MM-DD UTC).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6265,7 +6265,8 @@ export function useListAuditLogs<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams the filtered audit log as a CSV download.
|
* Streams the filtered audit log as a CSV download.
|
||||||
Honors the same `action`, `from`, and `to` filters as the list endpoint.
|
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
|
||||||
|
list endpoint.
|
||||||
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||||
Capped at 50,000 rows per export to bound response size.
|
Capped at 50,000 rows per export to bound response size.
|
||||||
|
|
||||||
|
|||||||
@@ -1811,6 +1811,18 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Exact action name to filter by (e.g. "group.force_delete").
|
description: Exact action name to filter by (e.g. "group.force_delete").
|
||||||
|
- in: query
|
||||||
|
name: forcedOnly
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: |
|
||||||
|
When true, restrict results to forced deletions only — i.e. rows
|
||||||
|
with action `group.force_delete`, `user.force_delete`,
|
||||||
|
`app.force_delete`, `service.force_delete`, or any
|
||||||
|
`group.delete`/`user.delete`/`app.delete` row whose metadata
|
||||||
|
contains `force: true`. Overrides the `action` filter when set.
|
||||||
- in: query
|
- in: query
|
||||||
name: from
|
name: from
|
||||||
required: false
|
required: false
|
||||||
@@ -1861,7 +1873,8 @@ paths:
|
|||||||
summary: Export filtered audit log as CSV (admin)
|
summary: Export filtered audit log as CSV (admin)
|
||||||
description: |
|
description: |
|
||||||
Streams the filtered audit log as a CSV download.
|
Streams the filtered audit log as a CSV download.
|
||||||
Honors the same `action`, `from`, and `to` filters as the list endpoint.
|
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
|
||||||
|
list endpoint.
|
||||||
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||||
Capped at 50,000 rows per export to bound response size.
|
Capped at 50,000 rows per export to bound response size.
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1871,6 +1884,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Exact action name to filter by (e.g. "group.force_delete").
|
description: Exact action name to filter by (e.g. "group.force_delete").
|
||||||
|
- in: query
|
||||||
|
name: forcedOnly
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: |
|
||||||
|
When true, restrict the export to forced deletions only.
|
||||||
|
See the list endpoint for the exact set of matching rows.
|
||||||
|
Overrides the `action` filter when set.
|
||||||
- in: query
|
- in: query
|
||||||
name: from
|
name: from
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -2250,6 +2250,7 @@ Filter by action (exact match) and/or a date range (inclusive).
|
|||||||
|
|
||||||
* @summary List audit log entries (admin)
|
* @summary List audit log entries (admin)
|
||||||
*/
|
*/
|
||||||
|
export const listAuditLogsQueryForcedOnlyDefault = false;
|
||||||
export const listAuditLogsQueryLimitDefault = 50;
|
export const listAuditLogsQueryLimitDefault = 50;
|
||||||
export const listAuditLogsQueryLimitMax = 200;
|
export const listAuditLogsQueryLimitMax = 200;
|
||||||
|
|
||||||
@@ -2261,6 +2262,12 @@ export const ListAuditLogsQueryParams = zod.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
|
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
|
||||||
|
forcedOnly: zod.coerce
|
||||||
|
.boolean()
|
||||||
|
.default(listAuditLogsQueryForcedOnlyDefault)
|
||||||
|
.describe(
|
||||||
|
"When true, restrict results to forced deletions only — i.e. rows\nwith action `group.force_delete`, `user.force_delete`,\n`app.force_delete`, `service.force_delete`, or any\n`group.delete`\/`user.delete`\/`app.delete` row whose metadata\ncontains `force: true`. Overrides the `action` filter when set.\n",
|
||||||
|
),
|
||||||
from: zod
|
from: zod
|
||||||
.date()
|
.date()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -2311,17 +2318,26 @@ export const ListAuditLogsResponse = zod.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams the filtered audit log as a CSV download.
|
* Streams the filtered audit log as a CSV download.
|
||||||
Honors the same `action`, `from`, and `to` filters as the list endpoint.
|
Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
|
||||||
|
list endpoint.
|
||||||
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||||
Capped at 50,000 rows per export to bound response size.
|
Capped at 50,000 rows per export to bound response size.
|
||||||
|
|
||||||
* @summary Export filtered audit log as CSV (admin)
|
* @summary Export filtered audit log as CSV (admin)
|
||||||
*/
|
*/
|
||||||
|
export const exportAuditLogsCsvQueryForcedOnlyDefault = false;
|
||||||
|
|
||||||
export const ExportAuditLogsCsvQueryParams = zod.object({
|
export const ExportAuditLogsCsvQueryParams = zod.object({
|
||||||
action: zod.coerce
|
action: zod.coerce
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
|
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
|
||||||
|
forcedOnly: zod.coerce
|
||||||
|
.boolean()
|
||||||
|
.default(exportAuditLogsCsvQueryForcedOnlyDefault)
|
||||||
|
.describe(
|
||||||
|
"When true, restrict the export to forced deletions only.\nSee the list endpoint for the exact set of matching rows.\nOverrides the `action` filter when set.\n",
|
||||||
|
),
|
||||||
from: zod
|
from: zod
|
||||||
.date()
|
.date()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
Reference in New Issue
Block a user