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
This commit is contained in:
riyadhafraa
2026-04-27 11:28:20 +00:00
parent c05ae786fc
commit 1ca5d5ae4b
10 changed files with 799 additions and 3 deletions
+124
View File
@@ -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<void> => {
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<number>`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;
+2
View File
@@ -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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+20
View File
@@ -357,6 +357,7 @@
"users": "المستخدمين",
"groups": "المجموعات",
"roles": "الأدوار",
"auditLog": "سجل التدقيق",
"userManagement": "إدارة المستخدمين",
"settings": "الإعدادات",
"menu": "القائمة"
@@ -435,6 +436,25 @@
"roles": "الأدوار"
}
},
"audit": {
"filters": {
"action": "الإجراء",
"allActions": "كل الإجراءات",
"from": "من",
"to": "إلى",
"apply": "تطبيق",
"reset": "إعادة ضبط"
},
"target": "الهدف: {{type}} #{{id}}",
"showing": "عرض {{shown}} من أصل {{total}}",
"empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.",
"loadMore": "عرض المزيد",
"errors": {
"invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.",
"invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".",
"fixFiltersFirst": "عدّل الفلاتر لتحميل الإدخالات."
}
},
"dashboardSoon": "إحصائيات اللوحة قريباً.",
"dashboard": {
"totalApps": "إجمالي التطبيقات",
+20
View File
@@ -354,6 +354,7 @@
"users": "Users",
"groups": "Groups",
"roles": "Roles",
"auditLog": "Audit log",
"userManagement": "User Management",
"settings": "Settings",
"menu": "Menu"
@@ -432,6 +433,25 @@
"roles": "Roles"
}
},
"audit": {
"filters": {
"action": "Action",
"allActions": "All actions",
"from": "From",
"to": "To",
"apply": "Apply",
"reset": "Reset"
},
"target": "Target: {{type}} #{{id}}",
"showing": "Showing {{shown}} of {{total}}",
"empty": "No audit log entries match these filters.",
"loadMore": "Load more",
"errors": {
"invalidDate": "Dates must be in YYYY-MM-DD format.",
"invertedDates": "\"From\" date must be on or before \"To\" date.",
"fixFiltersFirst": "Adjust the filters to load entries."
}
},
"dashboardSoon": "Dashboard widgets coming soon.",
"dashboard": {
"totalApps": "Total Apps",
+271 -3
View File
@@ -41,6 +41,8 @@ import {
useCreateRole,
useUpdateRole,
useDeleteRole,
useListAuditLogs,
getListAuditLogsQueryKey,
ApiError,
} from "@workspace/api-client-react";
import type {
@@ -56,13 +58,15 @@ import type {
GetAdminStatsParams,
Group,
ListUsersParams,
ListAuditLogsParams,
AuditLogEntry,
} from "@workspace/api-client-react";
import { ListUsersStatus } from "@workspace/api-client-react";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown } from "lucide-react";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -81,7 +85,7 @@ async function fetchAdminApps(): Promise<App[]> {
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" && <GroupsPanel />}
{section === "roles" && <RolesPanel />}
{section === "audit-log" && <AuditLogPanel />}
{section === "settings" && (
<div className="space-y-3">
@@ -3240,3 +3246,265 @@ function RolesPanel() {
</div>
);
}
// ===== 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 (
<div
className="glass-panel rounded-2xl p-3 space-y-2"
data-testid={`audit-row-${entry.id}`}
>
<div className="flex items-start gap-3">
<LeaderboardAvatar
src={actor?.avatarUrl ?? null}
initial={initial}
alt={actor?.username ?? ""}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium text-sm text-foreground truncate">
{actorLabel(actor, lang)}
</span>
{actor && (
<span className="text-xs text-muted-foreground">
@{actor.username}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<span
className="inline-block text-[11px] font-mono px-2 py-0.5 rounded-md bg-amber-100 text-amber-800"
data-testid={`audit-action-${entry.id}`}
>
{entry.action}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
})}
</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatAuditTimestamp(entry.createdAt, lang)}
</div>
</div>
{hasMetadata && (
<button
onClick={() => setExpanded((v) => !v)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground shrink-0"
aria-expanded={expanded}
data-testid={`audit-toggle-${entry.id}`}
>
<ChevronDown
size={16}
className={cn("transition-transform", expanded ? "rotate-180" : "")}
/>
</button>
)}
</div>
{expanded && hasMetadata && (
<pre
className="text-[11px] bg-slate-100/80 rounded-lg p-2 overflow-x-auto leading-snug whitespace-pre-wrap break-all"
dir="ltr"
data-testid={`audit-metadata-${entry.id}`}
>
{JSON.stringify(entry.metadata, null, 2)}
</pre>
)}
</div>
);
}
function AuditLogPanel() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [actionFilter, setActionFilter] = useState<string>("");
const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
const [pageLimit, setPageLimit] = useState<number>(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 (
<div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.action")}</Label>
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid="audit-action-filter"
>
<option value="">{t("admin.audit.filters.allActions")}</option>
{knownActions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
<Input
type="date"
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="audit-from-input"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.to")}</Label>
<Input
type="date"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="audit-to-input"
/>
</div>
<div className="flex items-end gap-2">
<Button
size="sm"
onClick={applyFilters}
className="flex-1"
data-testid="audit-apply-filters"
>
{t("admin.audit.filters.apply")}
</Button>
<Button
size="sm"
variant="outline"
onClick={resetFilters}
data-testid="audit-reset-filters"
>
{t("admin.audit.filters.reset")}
</Button>
</div>
</div>
{inputErrors.map((msg, i) => (
<p key={i} className="text-xs text-destructive">
{msg}
</p>
))}
<div className="text-xs text-muted-foreground">
{filtersValid
? t("admin.audit.showing", {
shown: showingCount,
total: totalCount,
})
: t("admin.audit.errors.fixFiltersFirst")}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : entries.length === 0 ? (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground">
{t("admin.audit.empty")}
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} lang={lang} />
))}
</div>
)}
{hasMore && (
<div className="flex justify-center pt-1">
<Button
size="sm"
variant="outline"
disabled={pageLimit >= 200 || isFetching}
onClick={loadMore}
data-testid="audit-load-more"
>
{isFetching ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.audit.loadMore")
)}
</Button>
</div>
)}
</div>
);
}
@@ -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;
};
+99
View File
@@ -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<AuditLogList> => {
return customFetch<AuditLogList>(getListAuditLogsUrl(params), {
...options,
method: "GET",
});
};
export const getListAuditLogsQueryKey = (params?: ListAuditLogsParams) => {
return [`/api/admin/audit-logs`, ...(params ? [params] : [])] as const;
};
export const getListAuditLogsQueryOptions = <
TData = Awaited<ReturnType<typeof listAuditLogs>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ListAuditLogsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAuditLogs>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListAuditLogsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAuditLogs>>> = ({
signal,
}) => listAuditLogs(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listAuditLogs>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListAuditLogsQueryResult = NonNullable<
Awaited<ReturnType<typeof listAuditLogs>>
>;
export type ListAuditLogsQueryError = ErrorType<ErrorResponse>;
/**
* @summary List audit log entries (admin)
*/
export function useListAuditLogs<
TData = Awaited<ReturnType<typeof listAuditLogs>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ListAuditLogsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAuditLogs>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListAuditLogsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
+135
View File
@@ -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
+65
View File
@@ -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).",
),
});