Skip to content

Add the audit log screen with export download and item history - #2460

Merged
pournasserian merged 6 commits into
v2from
2458-audit-screen
Oct 18, 2026
Merged

pournasserian merged 6 commits into
v2from
2458-audit-screen

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Closes #2458

Refs #2434

What

The third slice of F-AUD (#2424): the first screen on the log, behind the sign-in from #2450.

  • Core: IAuditExporter.ExportAsync now commits an audit.exported event in its own unit of work before it writes anything. The event names the current initiator and, when the selection is one item's history, that item as its subject. An invalid query or format records nothing. The query runs only when the export starts writing, so a selection that reaches the present contains its own export event. AuditErrorCodes replaces the string literals in AuditQuery and the exporter.
  • Admin: /audit, server-rendered without interactivity like the account pages, with an "Audit log" link in the main layout.
    • The export form takes from and to as UTC dates (both days included, either optional), an optional item type and id, and Download JSON or Download CSV.
    • The history lookup lists an item's events oldest first. Each event shows its operation, a User or Agent badge, the initiator's name, the time, and the agent credential, model, prompt hash, sources, change set, previous version and event id when they are set. It shows at most 200 events and points to the export for the rest.
    • AuditExportForm maps the posted fields to an AuditQuery and a format and reports every problem at once, including unreadable dates and a malformed id.
    • Strings go through IStringLocalizer, and the layout uses logical utilities only.
  • Host: POST /audit/export maps the form, calls the exporter and streams the file with Content-Disposition: attachment and Cache-Control: no-store. The file name is audit-log or audit-history-<id>, so nothing typed into the form reaches the header. It requires a signed-in user, and form binding applies the anti-forgery check. A rejected form goes back to /audit with its values and error codes in the query, and the page puts each message next to its field. GET /audit/export redirects to the page, for someone whose session ran out, who signs in again and comes back with a GET.
  • Component (refs Admin shell and component set #2434): Badge, with the shadcn variants and bUnit tests. The rest of the page uses Input, FormField, Button and Card unchanged.
  • CONTRIBUTING.md: the recorded export and the download endpoint.

Why

  • An export records itself. AUD-01 covers every operation, and an export is where data leaves the system. Recording it in Core means any later exposure, an API or an MCP tool, is recorded too without doing anything. Viewing an item's history on screen is a read and is not recorded. The event has no field for the window or the format, so they are not recorded. Adding them needs a details field on the audit event, which is a model change for a later slice.
  • One HTTP endpoint despite ADR-0006's "no admin HTTP API". A Razor page cannot answer with a file. The endpoint is not an API: it has no contract of its own, only maps the page's form onto the same Core service, and is protected like every page form. It lives in Host because Admin references only Core and the components package.
  • The history is a list, not a table. An event has up to fourteen fields, too wide for a table row. ADR-0011 puts tables on QuickGrid, a new package, which this screen does not need; the first tabular list, such as the browsable log in ADM-01, can bring it.
  • A forged token gets a 400, not a 500. The endpoint's 400 has no body, so the not-found page used to re-run as a POST and the anti-forgery check then threw on the same rejected form. Add OpenID Connect sign-in #2459 since stopped status code pages for anything but GET and HEAD across the host, so the endpoint needs nothing of its own; the forged-token test covers it.

No new NuGet dependency and no migration.

Tests

Unit: form mapping (UTC days with both ends included, empty fields select the full log, a one-day window, an item's history, every problem reported at once, unreadable dates per field, half an item, the return query keeps values and lists each code once). Exporter: the export event is committed before the first byte in both formats; an item's export names the item; an invalid query or format records nothing and opens no unit of work. bUnit: Badge default and variants.

Integration through the running host: anonymous GET /audit and POST /audit/export go to sign-in and record nothing. A signed-in JSON download of an item's history and a CSV download of a date range return the selected events, each export's own event included, as attachments with the right content type, file name, no-store and the CSP; each records one audit.exported event naming the user. A rejected export redirects to the page with both messages next to their fields, the values kept, and nothing recorded. A forged anti-forgery token gets 400 and records nothing. The history lookup shows an item's events oldest first with every field, encodes a source containing a script tag, leaves out other items' events, and handles an unknown and a malformed id. The layout links to the page. The integration tests from #2449 now also check that an item's export contains its own record and that an empty selection is still recorded.

Checked by hand in the browser: the page in light, dark and RTL, a rejected export coming back with its errors, the history of the signed-in user, and no CSP violations.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2 (or dev for a v1 security fix)
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 18, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:audit Provenance and audit (AUD) labels Oct 18, 2026
@pournasserian
pournasserian merged commit dad15eb into v2 Oct 18, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2458-audit-screen branch October 18, 2026 17:02
pournasserian added a commit that referenced this pull request Oct 18, 2026
Refs #2434

## What

- **Theme**: the root element carries `light` or `dark` when someone
chose one and no class otherwise, so the stylesheet follows the system
preference. The `dark` variant and the dark tokens apply with the `dark`
class or, with no stored choice, under `prefers-color-scheme: dark`;
token values are unchanged, and `color-scheme` now follows the theme so
native controls and scrollbars match. A toggle in the main layout header
(shadcn's mode toggle: Light, Dark, System on the existing DropdownMenu)
is handled by `admin.js`, which sets the class and keeps the choice in a
`fluentcms-theme` cookie. The host reads the cookie through
`IThemePreference` (defined in Admin, implemented in Host, like
`ICurrentInitiator`) and renders the class into `<html>`, so there is no
inline script and no flash of the wrong theme. `?theme=` is gone;
`?dir=rtl` stays until the UI culture sets the direction.
- **Reconnect**: `ReconnectModal.razor` renders the element Blazor looks
for (`#components-reconnect-modal`), so Blazor uses it instead of its
default display, whose injected `<style>` the CSP blocked. Blazor
reports the state as a class; `app.css` shows the matching part
(rejoining, retrying with the countdown, failed with Retry, paused with
Resume, resume failed with Reload) and `admin.js` runs the buttons,
retries when the tab becomes visible after a failure and reloads after a
rejected circuit, as the default display does. It is a status card at
the bottom end, not a modal. `data-permanent` keeps enhanced navigation
off it. The CSP is unchanged.
- **`/ui`** replaces `/ui-spike`. It renders every component in
`Components/Ui` (Button, Badge, Input, FormField, Card, Table, Dialog,
DropdownMenu, Command) with its keyboard hints, for any signed-in user
in every environment, as #2434 asks once sign-in exists. Server actions
report to a status line, so a round trip and a reconnect are visible.
`AdminOptions` goes with the Development-only gate. Keyboard hints use
shadcn's Kbd classes on a plain `<kbd>`; no component was added for the
page.
- **Two `admin.js` fixes found on `/ui`**: opening a DropdownMenu with
the arrow keys from its trigger called `showPopover()` without a source,
so the menu lost its anchor (in RTL it landed in the opposite corner);
it now passes the trigger. Command's active option used
`scrollIntoView`, which scrolled the page down to the palette on every
load; it now scrolls only the list.
- `CONTRIBUTING.md`: a sentence on `/ui`, and that a new component goes
on it in the same pull request.

## Boxes on #2434

Ticked now, already true on v2 before this pull request:

- Blazor Web App in `FluentCMS.Admin`: `App`, `Routes` and the layouts;
interactive server pages (`/ui`) next to static ones (setup, sign-in,
home, audit); composed by Host with
`MapRazorComponents<App>().AddInteractiveServerRenderMode()`;
`ProjectReferenceRulesTests` keeps Admin on Core only.
- Root element carries `dir` and the theme class. This pull request
moves the class from the query string to the stored choice.
- Components in `Components/Ui`, self-contained, no base classes, no
project or package.
- Localization and RTL: every string goes through `IStringLocalizer`
(there are no resource files yet, so the English text is the key), only
logical utilities, and `dir` flips the layout.

To tick when this merges: default reconnect handling; shadcn tokens with
light and dark switched by a root class and defaulting to the system
preference.

Left open: the interop module (focus trapping and roving tab index are
there, global keyboard shortcuts are not; they come with the desk, the
first screen that needs one), the tailwind-merge port, Lucide icons, the
rest of the initial set, bUnit per component (Button has none, and
keyboard behaviour lives in `admin.js`, which bUnit does not run) and
the `/ui` acceptance, which includes the WCAG checks.

## Why

- **A cookie the server reads, not local storage.** The class has to be
on `<html>` before the first paint, the CSP allows no inline script to
read storage that early, and `admin.js` is a deferred module. A cookie
lets the host render the class. The toggle itself stays in the browser,
so it works on static pages and while the circuit is down.
- **Light, Dark and System rather than a switch.** With two states,
choosing once loses the system default for good. It is shadcn's own mode
toggle.
- **Sun and Moon as inline SVG.** The paths are Lucide's, written inline
the way `DialogContent` and `CommandInput` already do. Generating icon
components from the set needs a tool or a package, so that box stays
open.
- **Not modal.** ADR-0011 chose native primitives partly because they
keep working through a lost circuit; a modal overlay would make the page
inert for exactly that gap.

No new dependency: no NuGet package, icon set or test tool.

## Tests

- bUnit: the toggle (named trigger, three choices left to `admin.js`, no
inline script, style or handler attributes); the reconnect markup (the
element Blazor looks for, one part per state, the countdown inside the
translated sentence, a button per failure, no inline style and no
display utility that would override the stylesheet); `/ui` renders every
component type in `Components/Ui` and fails when one is missing, lists
keyboard hints in every section, reports a click on the status line and
switches direction.
- Stylesheet, from the compiled CSS: dark applies with the class or,
with no stored choice, under `prefers-color-scheme: dark`; the reconnect
element is hidden until a state class shows one part.
- Host: the shell has the toggle and the reconnect element; the root
class follows the cookie (`dark`, `light`) and is absent for no or an
unknown value, on admin and account pages; `/ui` redirects to sign-in
when anonymous and renders for a signed-in user outside Development;
`/ui-spike` answers 404; the CSP has `style-src 'self'` and no
`unsafe-inline` or `unsafe-eval`; sign-in, home and `/ui` carry no
inline script, style or event handler. The sign-in tests now use `/ui`
as their protected route.

Checked by hand in the browser: the system theme with no choice; Light,
Dark and System from the header by mouse and keyboard (the choice
survives a reload, System clears it); the menu end-aligned in LTR, RTL
and at phone width; `/ui` in RTL; the reconnect card with the server
stopped (the dialog still opens meanwhile), pause and Resume, the failed
and resume-failed parts, and the reload after a rejected circuit. No CSP
violations in the console.

## Proposed, not done here

- Automated WCAG 2.1 AA checks for `/ui` (UX-04) need a new test tool,
for example axe-core run through Playwright. Playwright arrives with the
desk flows (F-DSK); the checks fit there.
- Unpublished runs outside Development serve the admin's `_content`
assets as empty 200 responses: the integration test host, and `dotnet
run --project src/FluentCMS.Host` as CONTRIBUTING describes it, run
without the stylesheet and `admin.js`. The existing asset test checks
only the status, so it passes. That is why the stylesheet test above
reads the compiled file. Worth its own issue.

## Rebased on v2

Merged `origin/v2` (cd2e26c) after #2460, #2466 and #2467. No
conflicts: the header keeps the audit and user nav entries next to the
theme toggle. The Table components from #2466 are now a section on
`/ui`, as the showcase test requires.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2` (or `dev` for a v1 security fix)
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:audit Provenance and audit (AUD) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit log screen: download an export and look up an item's history

1 participant