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
+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>
);
}