Files
Stirling-PDF/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
T
James Brunton 158187ac46 Fix Documents tab in Processor (#7569)
# Description of Changes
The Documents tab in the Processor is supposed to be available to all
Processor users, but because the API is built on top of the Audit data,
which is only for enterprise users, the API call always fails with 403.
This means that it never fills the query cache, so every time you go
back to the tab it has to reload all the data for a couple of seconds
(and will fail again). This fixes the API so that it's available to any
Processor user instead of just enterprise users. Also, the documents
data was only being written to the log on an enterprise license, so I've
changed it so that data is always tracked in the audit log because
otherwise the Documents tab would still be useless to non-enterprise
users.

The Audit Log tab was also available to all Processor users, but would
have the same issue where the table would never load because the API
would 403 as well. I've just made the Audit Log tab disabled for
non-enterprise users now. We might want to do something to signpost it a
bit more that it's an enterprise-specific feature, but it's better than
nothing for now.
2026-08-24 15:06:22 +00:00

199 lines
6.2 KiB
TypeScript

import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
column,
DataTable,
type DataTableColumn,
EmptyState,
MetricCard,
MetricStrip,
Tabs,
type TabItem,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { useAuditLog } from "@portal/queries/infrastructure";
import { HttpError } from "@portal/api/http";
import {
type AuditCategory,
type AuditEvent,
} from "@portal/api/infrastructure";
import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import {
AUDIT_CAT_LABEL,
AUDIT_STATUS_LABEL,
AUDIT_TONE,
} from "@portal/components/infrastructure/infraFormat";
type AuditFilter = "all" | AuditCategory;
// Enterprise-only: the Infrastructure view disables this tab for non-enterprise
// instances, so this component only ever renders when entitled. The backend still
// scopes the log to admins / team leads (403 -> forbidden state below).
export function AuditTab() {
const { t } = useTranslation();
const { tier } = useTier();
const [filter, setFilter] = useState<AuditFilter>("all");
const [exportOpen, setExportOpen] = useState(false);
const auditFilters: TabItem<AuditFilter>[] = [
{ key: "all", label: t("portal.infrastructure.audit.filters.all") },
{ key: "auth", label: t("portal.infrastructure.audit.filters.auth") },
{ key: "config", label: t("portal.infrastructure.audit.filters.config") },
{
key: "elevation",
label: t("portal.infrastructure.audit.filters.elevation"),
},
{ key: "policy", label: t("portal.infrastructure.audit.filters.policy") },
{
key: "processing",
label: t("portal.infrastructure.audit.filters.processing"),
},
{
key: "security",
label: t("portal.infrastructure.audit.filters.security"),
},
];
const cols: DataTableColumn<AuditEvent>[] = [
column.mono({
key: "timestamp",
header: t("portal.infrastructure.audit.columns.timestamp"),
sortable: true,
get: (e) => e.timestamp,
}),
column.text({
key: "event",
header: t("portal.infrastructure.audit.columns.event"),
sortable: true,
// "Category: action" on one line - the category as a bold label, no
// status-coloured dot. Colour is reserved for the Status column, where it
// actually signals an outcome.
label: (e) => t(AUDIT_CAT_LABEL[e.category]),
get: (e) => e.action,
}),
column.mono({
key: "actor",
header: t("portal.infrastructure.audit.columns.actor"),
sortable: true,
get: (e) => e.actor,
}),
column.text({
key: "target",
header: t("portal.infrastructure.audit.columns.target"),
sortable: true,
get: (e) => e.target,
}),
column.badge({
key: "status",
header: t("portal.infrastructure.audit.columns.status"),
sortable: true,
get: (e) => ({
tone: AUDIT_TONE[e.status],
label: t(AUDIT_STATUS_LABEL[e.status]),
}),
}),
column.number({
key: "latency",
header: t("portal.infrastructure.audit.columns.latency"),
sortable: true,
get: (e) => e.latencyMs,
format: (n) =>
t("portal.infrastructure.audit.latencyValue", { value: n }),
}),
];
const state = useAuditLog(tier);
const { data, error } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
// Backend returns 403 for scoped-out callers; show an access message, not an empty state.
const forbidden = error instanceof HttpError && error.status === 403;
const rows = useMemo(() => {
if (!data) return [];
if (filter === "all") return data.events;
return data.events.filter((e) => e.category === filter);
}, [data, filter]);
return (
<div className="portal-infra__stack">
<div className="portal-infra__audit-head">
<SectionHeader
title={t("portal.infrastructure.audit.heading")}
sub={t("portal.infrastructure.audit.subheading")}
/>
{/* Export is admin-only + whole-server, so only shown in the full-server view. */}
{data?.fullServer && (
<Button variant="secondary" onClick={() => setExportOpen(true)}>
{t("portal.infrastructure.audit.export.open")}
</Button>
)}
</div>
<AuditExportModal
open={exportOpen}
onClose={() => setExportOpen(false)}
/>
{data && (
<MetricStrip layout="row">
<MetricCard
label={t("portal.infrastructure.audit.metrics.totalEvents")}
value={data.summary.totalEvents.toLocaleString()}
/>
<MetricCard
label={t("portal.infrastructure.audit.metrics.policy")}
value={data.summary.policy.toLocaleString()}
/>
<MetricCard
label={t("portal.infrastructure.audit.metrics.processing")}
value={data.summary.processing.toLocaleString()}
/>
<MetricCard
label={t("portal.infrastructure.audit.metrics.config")}
value={data.summary.config.toLocaleString()}
/>
</MetricStrip>
)}
{!forbidden && (
<Tabs<AuditFilter>
items={auditFilters}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel={t("portal.infrastructure.audit.filterAriaLabel")}
/>
)}
<DataTable
columns={cols}
rows={rows}
rowKey={(e) => e.id}
loading={isLoading}
empty={
forbidden ? (
<EmptyState
size="compact"
title={t("portal.infrastructure.audit.forbidden.title")}
description={t(
"portal.infrastructure.audit.forbidden.description",
)}
/>
) : isEmpty ? (
<EmptyState
size="compact"
title={t("portal.infrastructure.audit.empty.title")}
description={t("portal.infrastructure.audit.empty.description")}
/>
) : (
t("portal.infrastructure.audit.noEventsInCategory")
)
}
/>
</div>
);
}