Skip to content

ui: the web interface - #38

Merged
cemhurturk merged 56 commits into
mainfrom
feat/web-interface
Sep 25, 2026
Merged

cemhurturk merged 56 commits into
mainfrom
feat/web-interface

Conversation

@cemhurturk

Copy link
Copy Markdown
Contributor

This adds the web interface: a React app the admin service serves on its own host, behind the same host guard, with every screen built on the admin API and nothing on it that a script cannot also do. It also adds the four API pieces the screens needed and could not get from what was there.

The screens

Overview, Links, Clicks, Domains, Settings and Account, each with one <h1>, in light and dark themes, and usable at 390 pixels wide. A link has its own report page, and its create and edit form covers every setting the API takes. The account screen enrols an authenticator with a QR code, shows recovery codes and a new API key once, and lists the sessions signed in, with the address each came from, the one place the product shows a whole address, because these are the operator's own devices.

Every time on screen is in the browser's own zone, chosen over a zone set once for the whole install. A day chart's days still begin on a whole hour of UTC, because the rollups are kept by the hour, so a zone that is not a whole number of hours from UTC has its days begin at 23:30 or 00:30, and the screen says so. That was forced by the rollup grain rather than preferred. Presets and pickers are computed across daylight-saving changes, including zones where a change skips midnight.

Editing a link sends only what changed, so a save cannot overwrite a field someone else changed meanwhile unless it was the same field. The export states its row count, and whether it will reach the cap, before the file starts.

Nothing polls. The numbers update on Refresh, or when the tab comes back into focus after a minute away. A report request that fails with 429 too_many_reports is retried, and at most two are in flight from one browser.

Secrets live in component state and nowhere else. A new key, the recovery codes and the enrolment secret are cleared by their dialog's close event, whatever closed it, and never reach storage, a URL or the console. The otpauth: URI is never rendered.

The security policy, and what it forced

The interface is served with default-src 'none', scripts, styles, images, fonts and connections from 'self' only, frame-ancestors 'none' and base-uri 'none'. Three choices follow from that and were forced rather than preferred. The alternative was a nonce minted per response, which would make the page uncacheable, for components the platform already provides:

  • Dialogs are the native <dialog>, not a Radix overlay, because the overlay primitives inject a <style> element at runtime that the policy refuses.
  • The chart is hand-drawn SVG, not a charting library, because the libraries considered set inline styles.
  • The QR code is <rect> elements, not a data URL, so img-src stays 'self'.

A source-rule test refuses colour in source, dangerouslySetInnerHTML, eval, runtime imports from service packages and Radix overlay imports. A build-output test refuses inline script and style, and checks that the bundle evaluates and renders. Static assets are immutable under /assets/. The page fallback is narrow: a GET, not under /api, /health or /assets, and with no dot in the path. Range requests to an asset answer 200, not 206.

The build now copies brand/ into the build stage, and .dockerignore no longer excludes it, because the interface's stylesheet imports the brand's generated tokens. Only the build stage reads it: Vite writes what it takes into packages/ui/dist, and nothing at runtime reads brand/.

What the API gained

  • offset on the chart, in whole hours from −12 to 14, which moves where a day bucket begins.
  • A link breakdown dimension, labelled with each link's domain and slug.
  • GET /api/clicks/count, so the export can state its row count and whether it will reach the cap before the download starts.
  • The link list is newest first, with a keyset cursor exact to the microsecond and a q search, and each link carries createdAt and capUsed.
  • GET /api/status: the newest reported hour, whether reporting is on, IP data from the manifest through a read-only mount, and the alert count. It is what the header's freshness line and the "domains need attention" link read, and is as far as admin: tell the operator when a domain stops verifying #26 goes here: the install surfaces a failing domain on every screen, and still sends nothing.

Postgres 009 adds one index, links (created_at DESC, id DESC), for the list's order. SCHEMA_VERSION is 9. The migration is not CONCURRENTLY, because the migrator runs each file in a transaction. Its lock blocks writes to links only while it builds, and does not block the redirect's snapshot load.

What it does not do yet

The domain list keeps its cap of 500 and says on screen when it is truncated, so paging it is still #22. There is no time zone setting on the install, only the browser's own zone. There are no bulk operations, and no local development mode (#23), so trying the interface needs a real admin host, the same as the API does. A CSV of a report is made in the browser, not streamed by the API. A report the browser abandons still runs to completion on the server and holds its slot until then (#37).

Closes #25
Closes #24

Verification

The gate ran in CI's order: pnpm lint, pnpm lint:sh, pnpm typecheck and pnpm build, then pnpm test. pnpm test ran 65 files, 1,550 tests for the services and 44 files, 560 tests for the interface, and the interface suite passed five runs in a row. The stack suites passed 86 tests across seven files. That includes a browser suite that runs the built interface in Playwright against the real stack over TLS. It signs in with a second factor it computes, creates a link through the form, clicks it, sees the click arrive, and exports it. It fails on any content-security-policy violation on any page, and on any signed-in screen that scrolls sideways at 390 pixels. With CLICKMONK_SHOTS=1 it writes all 36 screenshots, of every screen in both themes at two widths. The durability suite still loses no click across restarts.

The screenshots were read, and they changed things. They showed tables that scrolled the whole page sideways at 390 pixels, a theme toggle whose icon ignored a system change, and native controls drawn light in the dark theme. Each is fixed, and the first is now a failing check. Every pairing the interface draws is measured by the brand's contrast script, which fails rather than asserts. Three pairings were added to brand/contrast-report.txt. Measuring the colours as drawn found four more: focus rings at half opacity, an active nav item and a pressed chart toggle that differed from their surroundings only by a fill at about 1.2:1. They now use roles that measure 5.9:1 or better, and a source rule refuses a translucent focus ring. Content refreshing in place still dims to half opacity while it reloads.

Every guard was mutated individually and caught by exactly its own test. Where a mutation passed, the test was the defect, and it was repaired in its own commit.

Cem Hurturk added 30 commits September 25, 2026 10:03
public/theme.js is added to Biome's ignore list: it is a deliberate ES5
classic script (runs before anything else, in any browser, ahead of the
content security policy allowing no inline script at all), not code Biome's
ordinary rules should reformat or flag for var/empty-catch.
…pace/re-export imports in the source rules

The colour rule missed named colours (fill="white", color: white;) and the
four palettes the installed Tailwind version added (mauve, olive, mist,
taupe). The runtime-import rule missed a side-effect import and
false-positived on a multi-line import type. The Radix rule missed a
namespace import and a re-export. The build-output test for a missing or
unknown stored theme only exercised the unknown case.
…idden-tab guard, the footer's closing sentence, and the checking phase after sign-in; say when a session did not stick
…and keep the picker in step with the address
…t its own query, and drop stale data after a failure
Cem Hurturk added 26 commits September 25, 2026 18:08
@cemhurturk
cemhurturk merged commit 34605a1 into main Sep 25, 2026
5 checks passed
@cemhurturk
cemhurturk deleted the feat/web-interface branch September 25, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

admin: a web interface for the admin API admin: reports and the click log over the API

1 participant