From 1ca5d5ae4b0b8ce1beb378d171ab01ec97f0f148 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 27 Apr 2026 11:28:20 +0000 Subject: [PATCH] Add admin Audit Log view (task-84) Original task: Show admins a history of sensitive actions. Forced group deletions already write to the new `audit_logs` table; admins now have a UI to browse and filter those entries. API - New OpenAPI endpoint GET /admin/audit-logs (admin-only) with optional filters: action (exact), from / to (YYYY-MM-DD, inclusive), limit (1-200, default 50), offset (default 0). - New schemas: AuditLogActor, AuditLogEntry, AuditLogList. List response includes paginated `entries`, `totalCount`, `nextOffset`, and a distinct `actions` list to power the filter dropdown. - New `audit` tag added to the spec; ran orval codegen so api-client-react / api-zod expose useListAuditLogs et al. - Implemented `artifacts/api-server/src/routes/audit.ts` and registered it in routes/index.ts. Validates date format / order, joins users for actor info, and orders newest-first. UI (artifacts/tx-os/src/pages/admin.tsx) - New "Audit log" entry under the User Management nav group (icon: ScrollText). Section deep-link works via #section=audit-log. - New AuditLogPanel + AuditLogRow components: action chip + target, formatted timestamp, actor avatar/name, expandable JSON metadata. - Filters bar: action dropdown (populated from API's distinct actions), from/to date inputs, Apply / Reset, plus a "Load more" control that increases the page size (caps at 200, the API limit). Read-only by design. - Added en/ar locale strings for nav.auditLog and the audit subtree. Verification - Typecheck: pnpm -w run typecheck (clean). - API smoke tested via curl: 401 unauthenticated, validation errors for bad / inverted dates, returns the seeded `group.force_delete` entry once produced. - End-to-end browser test (testing skill): logged in as admin, navigated to /admin#section=audit-log, verified the row, expanded metadata, exercised filters (empty state + reset). No deviations from the task description. Replit-Task-Id: 5b5bf9b1-6937-43c3-85c9-81f0c19a5e49 --- artifacts/api-server/src/routes/audit.ts | 124 ++++++++ artifacts/api-server/src/routes/index.ts | 2 + artifacts/tx-os/public/opengraph.jpg | Bin 25890 -> 24623 bytes artifacts/tx-os/src/locales/ar.json | 20 ++ artifacts/tx-os/src/locales/en.json | 20 ++ artifacts/tx-os/src/pages/admin.tsx | 274 +++++++++++++++++- .../src/generated/api.schemas.ts | 63 ++++ lib/api-client-react/src/generated/api.ts | 99 +++++++ lib/api-spec/openapi.yaml | 135 +++++++++ lib/api-zod/src/generated/api.ts | 65 +++++ 10 files changed, 799 insertions(+), 3 deletions(-) create mode 100644 artifacts/api-server/src/routes/audit.ts diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts new file mode 100644 index 00000000..db14886d --- /dev/null +++ b/artifacts/api-server/src/routes/audit.ts @@ -0,0 +1,124 @@ +import { Router, type IRouter } from "express"; +import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { auditLogsTable, usersTable } from "@workspace/db"; +import { requireAdmin } from "../middlewares/auth"; + +const router: IRouter = Router(); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function parseUtcDate(s: string): Date | null { + if (!DATE_RE.test(s)) return null; + const d = new Date(`${s}T00:00:00.000Z`); + return Number.isNaN(d.getTime()) ? null : d; +} + +router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise => { + const action = typeof req.query.action === "string" ? req.query.action.trim() : ""; + const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : ""; + const toStr = typeof req.query.to === "string" ? req.query.to.trim() : ""; + + let fromDate: Date | null = null; + let toDateExclusive: Date | null = null; + if (fromStr) { + fromDate = parseUtcDate(fromStr); + if (!fromDate) { + res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" }); + return; + } + } + if (toStr) { + const toDate = parseUtcDate(toStr); + if (!toDate) { + res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" }); + return; + } + toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000); + } + if (fromDate && toDateExclusive && fromDate >= toDateExclusive) { + res.status(400).json({ error: "'from' must be on or before 'to'" }); + return; + } + + const limitRaw = Number(req.query.limit); + const offsetRaw = Number(req.query.offset); + const limit = + Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.floor(limitRaw), 200) + : 50; + const offset = + Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0; + + const conditions: SQL[] = []; + if (action) conditions.push(eq(auditLogsTable.action, action)); + if (fromDate) conditions.push(gte(auditLogsTable.createdAt, fromDate)); + if (toDateExclusive) conditions.push(lt(auditLogsTable.createdAt, toDateExclusive)); + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const baseQuery = db + .select({ + id: auditLogsTable.id, + action: auditLogsTable.action, + targetType: auditLogsTable.targetType, + targetId: auditLogsTable.targetId, + metadata: auditLogsTable.metadata, + createdAt: auditLogsTable.createdAt, + actorUserId: auditLogsTable.actorUserId, + actorUsername: usersTable.username, + actorDisplayNameAr: usersTable.displayNameAr, + actorDisplayNameEn: usersTable.displayNameEn, + actorAvatarUrl: usersTable.avatarUrl, + }) + .from(auditLogsTable) + .leftJoin(usersTable, eq(usersTable.id, auditLogsTable.actorUserId)); + + const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery) + .orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id)) + .limit(limit) + .offset(offset); + + const countQuery = db + .select({ count: sql`count(*)::int` }) + .from(auditLogsTable); + const [{ count: totalCount } = { count: 0 }] = await (whereClause + ? countQuery.where(whereClause) + : countQuery); + + const actionRows = await db + .selectDistinct({ action: auditLogsTable.action }) + .from(auditLogsTable) + .orderBy(auditLogsTable.action); + + const entries = rows.map((r) => ({ + id: r.id, + action: r.action, + targetType: r.targetType, + targetId: r.targetId, + metadata: r.metadata, + createdAt: r.createdAt, + actor: + r.actorUserId != null && r.actorUsername != null + ? { + id: r.actorUserId, + username: r.actorUsername, + displayNameAr: r.actorDisplayNameAr, + displayNameEn: r.actorDisplayNameEn, + avatarUrl: r.actorAvatarUrl, + } + : null, + })); + + const nextOffset = offset + rows.length < totalCount ? offset + rows.length : null; + + res.json({ + entries, + totalCount, + limit, + offset, + nextOffset, + actions: actionRows.map((a) => a.action), + }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index c3dd5fcd..7e9fe5c2 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -13,6 +13,7 @@ import settingsRouter from "./settings"; import notesRouter from "./notes"; import groupsRouter from "./groups"; import rolesRouter from "./roles"; +import auditRouter from "./audit"; const router: IRouter = Router(); @@ -30,5 +31,6 @@ router.use(settingsRouter); router.use(notesRouter); router.use(groupsRouter); router.use(rolesRouter); +router.use(auditRouter); export default router; diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 41ce5b81284da418ae31f76573b2fc67cd8c4970..d1100405f26b05144245080dc91af986cfeb0978 100644 GIT binary patch literal 24623 zcmeFZ2Ut^C+bFt05j&#PNc%t$A%X~@*U>~!s-tXuXj3x*=0EnY zp!eOuxZkHA|IG&GUG1jN4nZB4{>_;Gr{*JfZQLX`d(^zmU}jM6=4Px`&+8~uro+qf7R zfa77X^FZ#6gr2rBypg1GwsZabF(K`f6UsFe11 z+uty`f5+|4-gE~+$!Kc}L5rCXbix#Z&a^_%(c61{p!he>CIGs4z;n5RpB>~3*+2r2 zCgcKPAaPK;0$qkAA(`C)=qhyRAk!hHgNF_=9b#rabol70qeqV%Im&jD<@hO1HZCqs zHVzIL?|FXMSs@+{4gt7;knn|zq8GXOFI~PQa{0W-MG^WW49v{TM-Lx8bM)vL5f}$d zxH~JP3T>P?=qJvYB9am!Ai{W&eVb3@Oa!ov0e{u2k5Vr2gj=6Jn~&ohmnq0vcr z|3T>1X@t-%L5)tl&@Fh_^c95Q`$9x$ORToTU32cXjAi?G-O>&drO0}Ck4;y+47Fk-r#K8kht6&9wUX35laf|%e=`&i6L`HVZ6?V^%i(0 z@WQPdob++cE=A~^-;HyO&1?(1ZZsB|-ivY4ClaJg4UuF72Emakf{UgTD+KAOtL+_s ztS^Ha5()}UH}pDX_zQDX$9tk&I@OvP*XaI1mwV?Fx)yw{(b%Xw_^m=C?|T2W?k zxNmofmhp9Dga&_2rG4Gbv&*m;wQG;7+?kHetb6W<2#Q;&jvJIJADfK0|4s2H&1Bih za7&U{Iql-Q&x2z3*I7r;|3mE`(?^+>U*%t%W(c%@^IA>K9;vQ|v@&~#K7rK8!*lqt z{w!V8-blMC+WyY*ym9JApHuhyd=y4M8r^A&IAw%H5|GahKf1|keT!K2ebS@S9OdDV54&uq2@v8fO*2zlIEvGJIYz-Dfz`8Fdgz%o zy1+{clMzpeNq490Ew-}*u0A~Ge3((V@s&vohm={Ev9n7N29Y7g-cB*imo)XrGZrn3 z&S|zSZUp#Xd^tMJy0}sF{OKaI8%-LFhmVRL-Fwy$#t=F;1^iebg?>1&{8J={;^Fhl zIU=jyhelPSz^V(quB^QnfS(orFtb$|;8&1}U6as{P7E&k5N_#z{!N%`PR*z5&EitW z3-Xo&=Pz~@iOXL|*y<_Xid&2=4TzlGp-yeEoZnQ8DwrJSKN-k;LA=1=(k}Gjjr`Z{ zmHZ7$6Z`dJomMM13j;D{hRKicDRuDm%AEv78(QpJ8vdl#xfqv^Uz)spDX}!G2}UlZ zlWnynk2iChX-fAS1`Ehto;=AC=bO5w+&nUd?4sQn`hLEV1)XOKf&6}BL0|1;Jf
    }%gr&Dth4VK$149_m4&u*P&Xq?)B(PzT{SniF; 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 { return res.json(); } -type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "settings"; +type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings"; function LeaderboardAvatar({ src, @@ -276,6 +280,7 @@ export default function AdminPage() { { key: "users", label: t("admin.nav.users"), Icon: UsersIcon }, { key: "groups", label: t("admin.nav.groups"), Icon: Shield }, { key: "roles", label: t("admin.nav.roles"), Icon: KeyRound }, + { key: "audit-log", label: t("admin.nav.auditLog"), Icon: ScrollText }, ], }, { key: "settings", label: t("admin.nav.settings"), Icon: Settings }, @@ -288,7 +293,7 @@ export default function AdminPage() { const sectionMatch = hash.match(/section=([a-z-]+)/); if (sectionMatch) { const s = sectionMatch[1] as AdminSection; - if (["dashboard", "apps", "services", "users", "groups", "roles", "settings"].includes(s)) { + if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) { setSection(s); } } @@ -803,6 +808,7 @@ export default function AdminPage() { )} {section === "groups" && } {section === "roles" && } + {section === "audit-log" && } {section === "settings" && (
    @@ -3240,3 +3246,265 @@ function RolesPanel() {
    ); } + +// ===== Audit Log Panel ===== +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function formatAuditTimestamp(value: string | Date, lang: string): string { + const d = value instanceof Date ? value : new Date(value); + return new Intl.DateTimeFormat(lang === "ar" ? "ar-u-nu-latn" : "en-US", { + dateStyle: "medium", + timeStyle: "short", + numberingSystem: "latn", + hour12: false, + }).format(d); +} + +function actorLabel(actor: AuditLogEntry["actor"], lang: string): string { + if (!actor) return "—"; + const ar = actor.displayNameAr ?? null; + const en = actor.displayNameEn ?? null; + const preferred = lang === "ar" ? ar ?? en : en ?? ar; + return preferred && preferred.trim() ? preferred : actor.username; +} + +function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const hasMetadata = + entry.metadata != null && Object.keys(entry.metadata as object).length > 0; + const actor = entry.actor; + const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase(); + return ( +
    +
    + +
    +
    + + {actorLabel(actor, lang)} + + {actor && ( + + @{actor.username} + + )} +
    +
    + + {entry.action} + + + {t("admin.audit.target", { + type: entry.targetType, + id: entry.targetId ?? "—", + })} + +
    +
    + {formatAuditTimestamp(entry.createdAt, lang)} +
    +
    + {hasMetadata && ( + + )} +
    + {expanded && hasMetadata && ( +
    +          {JSON.stringify(entry.metadata, null, 2)}
    +        
    + )} +
    + ); +} + +function AuditLogPanel() { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const [actionFilter, setActionFilter] = useState(""); + const [fromInput, setFromInput] = useState(""); + const [toInput, setToInput] = useState(""); + const [appliedFrom, setAppliedFrom] = useState(""); + const [appliedTo, setAppliedTo] = useState(""); + const [pageLimit, setPageLimit] = useState(50); + + const fromValid = !appliedFrom || DATE_RE.test(appliedFrom); + const toValid = !appliedTo || DATE_RE.test(appliedTo); + const orderValid = + !appliedFrom || !appliedTo || appliedFrom <= appliedTo; + const filtersValid = fromValid && toValid && orderValid; + + const params: ListAuditLogsParams = { + limit: pageLimit, + offset: 0, + ...(actionFilter ? { action: actionFilter } : {}), + ...(appliedFrom ? { from: appliedFrom } : {}), + ...(appliedTo ? { to: appliedTo } : {}), + }; + + const queryKey = getListAuditLogsQueryKey(params); + const { data, isLoading, isFetching } = useListAuditLogs(params, { + query: { queryKey, enabled: filtersValid }, + }); + + const inputErrors: string[] = []; + if (!fromValid || !toValid) inputErrors.push(t("admin.audit.errors.invalidDate")); + else if (!orderValid) inputErrors.push(t("admin.audit.errors.invertedDates")); + + const knownActions = data?.actions ?? []; + const entries = data?.entries ?? []; + const totalCount = data?.totalCount ?? 0; + const showingCount = entries.length; + const hasMore = data?.nextOffset != null; + + const applyFilters = () => { + setAppliedFrom(fromInput.trim()); + setAppliedTo(toInput.trim()); + setPageLimit(50); + }; + + const resetFilters = () => { + setActionFilter(""); + setFromInput(""); + setToInput(""); + setAppliedFrom(""); + setAppliedTo(""); + setPageLimit(50); + }; + + const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200)); + + return ( +
    +
    +
    +
    + + +
    +
    + + setFromInput(e.target.value)} + className="bg-white/70 border-slate-200" + data-testid="audit-from-input" + /> +
    +
    + + setToInput(e.target.value)} + className="bg-white/70 border-slate-200" + data-testid="audit-to-input" + /> +
    +
    + + +
    +
    + {inputErrors.map((msg, i) => ( +

    + {msg} +

    + ))} +
    + {filtersValid + ? t("admin.audit.showing", { + shown: showingCount, + total: totalCount, + }) + : t("admin.audit.errors.fixFiltersFirst")} +
    +
    + + {isLoading ? ( +
    + +
    + ) : entries.length === 0 ? ( +
    + {t("admin.audit.empty")} +
    + ) : ( +
    + {entries.map((entry) => ( + + ))} +
    + )} + + {hasMore && ( +
    + +
    + )} +
    + ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index e8b0ca7c..ce6c99da 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -763,6 +763,45 @@ export interface AdminAppOpensByUser { opens: AppOpenByUserEntry[]; } +export interface AuditLogActor { + id: number; + username: string; + /** @nullable */ + displayNameAr?: string | null; + /** @nullable */ + displayNameEn?: string | null; + /** @nullable */ + avatarUrl?: string | null; +} + +/** + * @nullable + */ +export type AuditLogEntryMetadata = { [key: string]: unknown } | null; + +export interface AuditLogEntry { + id: number; + action: string; + targetType: string; + /** @nullable */ + targetId: number | null; + /** @nullable */ + metadata: AuditLogEntryMetadata; + createdAt: string; + actor: AuditLogActor | null; +} + +export interface AuditLogList { + entries: AuditLogEntry[]; + totalCount: number; + limit: number; + offset: number; + /** @nullable */ + nextOffset: number | null; + /** All distinct action names present in the audit log (for building filter UI). */ + actions: string[]; +} + export type LeaveConversationBody = { /** When the leaver is the only admin, optionally name a specific remaining member to promote. Omit (or send null) to keep the @@ -879,3 +918,27 @@ export const GetAdminAppOpensByUserRange = { "90d": "90d", custom: "custom", } as const; + +export type ListAuditLogsParams = { + /** + * Exact action name to filter by (e.g. "group.force_delete"). + */ + action?: string; + /** + * Start date (inclusive, YYYY-MM-DD UTC). + */ + from?: string; + /** + * End date (inclusive, YYYY-MM-DD UTC). + */ + to?: string; + /** + * @minimum 1 + * @maximum 200 + */ + limit?: number; + /** + * @minimum 0 + */ + offset?: number; +}; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 1614482e..7a3b9bec 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -25,6 +25,7 @@ import type { AdminStats, App, AppSettings, + AuditLogList, AuthUser, ConversationWithDetails, CreateAppBody, @@ -47,6 +48,7 @@ import type { HealthStatus, HomeStats, LeaveConversationBody, + ListAuditLogsParams, ListGroupsParams, ListUsersParams, LoginBody, @@ -5862,3 +5864,100 @@ export function useGetAdminAppOpensByUser< return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * Returns recent entries from the audit log, newest first. Admin only. +Filter by action (exact match) and/or a date range (inclusive). + + * @summary List audit log entries (admin) + */ +export const getListAuditLogsUrl = (params?: ListAuditLogsParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/audit-logs?${stringifiedParams}` + : `/api/admin/audit-logs`; +}; + +export const listAuditLogs = async ( + params?: ListAuditLogsParams, + options?: RequestInit, +): Promise => { + return customFetch(getListAuditLogsUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getListAuditLogsQueryKey = (params?: ListAuditLogsParams) => { + return [`/api/admin/audit-logs`, ...(params ? [params] : [])] as const; +}; + +export const getListAuditLogsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params?: ListAuditLogsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListAuditLogsQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listAuditLogs(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListAuditLogsQueryResult = NonNullable< + Awaited> +>; +export type ListAuditLogsQueryError = ErrorType; + +/** + * @summary List audit log entries (admin) + */ + +export function useListAuditLogs< + TData = Awaited>, + TError = ErrorType, +>( + params?: ListAuditLogsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListAuditLogsQueryOptions(params, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index b215c169..3200cd05 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -32,6 +32,8 @@ tags: description: Dashboard stats - name: storage description: Object storage upload and serving endpoints + - name: audit + description: Admin audit log paths: /healthz: @@ -1657,6 +1659,64 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /admin/audit-logs: + get: + operationId: listAuditLogs + tags: [audit] + summary: List audit log entries (admin) + description: | + Returns recent entries from the audit log, newest first. Admin only. + Filter by action (exact match) and/or a date range (inclusive). + parameters: + - in: query + name: action + required: false + schema: + type: string + description: Exact action name to filter by (e.g. "group.force_delete"). + - in: query + name: from + required: false + schema: + type: string + format: date + description: Start date (inclusive, YYYY-MM-DD UTC). + - in: query + name: to + required: false + schema: + type: string + format: date + description: End date (inclusive, YYYY-MM-DD UTC). + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + "200": + description: Page of audit log entries + content: + application/json: + schema: + $ref: "#/components/schemas/AuditLogList" + "400": + description: Invalid filter parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + components: schemas: HealthStatus: @@ -3032,3 +3092,78 @@ components: - userId - username - count + + AuditLogActor: + type: object + properties: + id: + type: integer + username: + type: string + displayNameAr: + type: ["string", "null"] + displayNameEn: + type: ["string", "null"] + avatarUrl: + type: ["string", "null"] + required: + - id + - username + + AuditLogEntry: + type: object + properties: + id: + type: integer + action: + type: string + targetType: + type: string + targetId: + type: ["integer", "null"] + metadata: + type: ["object", "null"] + additionalProperties: true + createdAt: + type: string + format: date-time + actor: + oneOf: + - $ref: "#/components/schemas/AuditLogActor" + - type: "null" + required: + - id + - action + - targetType + - targetId + - metadata + - createdAt + - actor + + AuditLogList: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/AuditLogEntry" + totalCount: + type: integer + limit: + type: integer + offset: + type: integer + nextOffset: + type: ["integer", "null"] + actions: + type: array + items: + type: string + description: All distinct action names present in the audit log (for building filter UI). + required: + - entries + - totalCount + - limit + - offset + - nextOffset + - actions diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 6e4e7ad3..75e8f17f 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1946,3 +1946,68 @@ export const GetAdminAppOpensByUserResponse = zod.object({ }), ), }); + +/** + * Returns recent entries from the audit log, newest first. Admin only. +Filter by action (exact match) and/or a date range (inclusive). + + * @summary List audit log entries (admin) + */ +export const listAuditLogsQueryLimitDefault = 50; +export const listAuditLogsQueryLimitMax = 200; + +export const listAuditLogsQueryOffsetDefault = 0; +export const listAuditLogsQueryOffsetMin = 0; + +export const ListAuditLogsQueryParams = zod.object({ + action: zod.coerce + .string() + .optional() + .describe('Exact action name to filter by (e.g. \"group.force_delete\").'), + from: zod + .date() + .optional() + .describe("Start date (inclusive, YYYY-MM-DD UTC)."), + to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."), + limit: zod.coerce + .number() + .min(1) + .max(listAuditLogsQueryLimitMax) + .default(listAuditLogsQueryLimitDefault), + offset: zod.coerce + .number() + .min(listAuditLogsQueryOffsetMin) + .default(listAuditLogsQueryOffsetDefault), +}); + +export const ListAuditLogsResponse = zod.object({ + entries: zod.array( + zod.object({ + id: zod.number(), + action: zod.string(), + targetType: zod.string(), + targetId: zod.number().nullable(), + metadata: zod.record(zod.string(), zod.unknown()).nullable(), + createdAt: zod.coerce.date(), + actor: zod.union([ + zod.object({ + id: zod.number(), + username: zod.string(), + displayNameAr: zod.string().nullish(), + displayNameEn: zod.string().nullish(), + avatarUrl: zod.string().nullish(), + }), + zod.null(), + ]), + }), + ), + totalCount: zod.number(), + limit: zod.number(), + offset: zod.number(), + nextOffset: zod.number().nullable(), + actions: zod + .array(zod.string()) + .describe( + "All distinct action names present in the audit log (for building filter UI).", + ), +});