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:
@@ -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;
|
||||
@@ -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 |
@@ -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": "إجمالي التطبيقات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).",
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user