diff --git a/.dockerignore b/.dockerignore index 89ed96a..c074ee4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,5 +7,4 @@ node_modules .env test docs -brand *.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bbf4f3..7a683c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,9 +88,10 @@ jobs: # `durability` both call `down -v`. stack: runs-on: ubuntu-latest - # Six suites now, each bringing the stack up for itself, one of them - # obtaining certificates from a local authority. Three and a half minutes - # with warm images locally; a cold runner builds the image first. + # Seven suites now, each bringing the stack up for itself, one of them + # obtaining certificates from a local authority and one driving a browser + # from Playwright's image. Four minutes with warm images locally; a cold + # runner builds the image first and pulls the browser's (about 1 GB). timeout-minutes: 40 steps: - uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index 58ffab3..d37e4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ docker-compose.override.yml # The TLS suite writes the local certificate authority root and the test DNS # zone here before it starts the stack. test/stack/tmp/ + +# tsc -b's own incremental build output for the interface package. Vite owns +# dist/ (already ignored above); this is a separate directory so the two +# builds never delete each other's output, and nothing reads it. +packages/ui/dist-ts/ diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..df4131c --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,6 @@ +# Test fixtures that look like secrets and are not: a made-up API key whose +# secret part is base64 of "secret-secret-…", and a DNS verification token of +# hex digits in order. Each line is gitleaks' fingerprint of the commit that +# introduced the fixture; history is kept, so the fingerprint is the only fix. +d0334a0c90754bdca095ee6967253ae30ca60d43:packages/ui/src/screens/account/ApiKeys.test.tsx:generic-api-key:8 +792d0fb97eafb920a7857ca969d10bea93ee45e4:packages/ui/src/screens/domains/Domains.test.tsx:generic-api-key:11 diff --git a/CLAUDE.md b/CLAUDE.md index 4670994..18d8bf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,10 +50,12 @@ pass), the admin API (one account, sessions, TOTP with recovery codes, API keys, domain/link/settings CRUD, answering on `CLICKMONK_ADMIN_HOST` alone — unset, every route but `/health` is a 503 and links serve exactly as before), reporting over that API (a summary, a chart by -hour or day, a breakdown over nine dimensions, the raw click log and a streamed CSV of it, -all reading hourly rollups except the log and its export), retention (two periods on the -settings row, enforced hourly by the worker, dropping raw clicks a partition at a time and -blanking the address on a click in place), the CLI (`migrate`, +hour or day, a breakdown over ten dimensions, the raw click log and a streamed CSV of it, +all reading hourly rollups except the log and its export), the web interface (a React app +the admin service serves from that same host, behind the same host guard — every screen +built on the admin API and nothing it can reach that a script cannot), retention (two +periods on the settings row, enforced hourly by the worker, dropping raw clicks a partition +at a time and blanking the address on a click in place), the CLI (`migrate`, `domain add|list|verify`, `link add`, `settings show|set`, `ipdata status|update`, `admin create|passwd`, `admin totp disable`, `apikey create|list|revoke`), `install.sh`, a Compose stack, and the restart durability and stack test suites. @@ -75,31 +77,28 @@ mistyped host name is not a reason to take every link on the install down. What does not exist yet, and must not be implied by any documentation: -- **A web UI.** There is an admin API; nothing draws it yet, and it cannot be reached over - plain HTTP at all — the admin host answers 308, and the session cookie is `Secure` with - the `__Host-` prefix — so unlike a link domain it cannot be tried on a laptop. - **More than one admin account.** One account is the whole of the access control: no roles, no second person, no attribution of who did what. An API key is the only credential that can be handed out and revoked on its own, and it is not a person. - **A way back in over the API when both factors are lost.** Removing the second factor there requires the second factor. `clickmonk admin totp disable` is the answer, and it is a command on the server for that reason. -- **Anything drawn.** The reports are JSON and nothing plots them. There is no dashboard, - so how fresh the numbers are is `newestHour` in the summary and the chart rather than - something on a screen. -- **A CSV of a report.** The export is the click log. A summary or a breakdown is one small - answer already, and turning it into a file belongs to the web interface. -- **A time zone.** Every window, bucket and retention period is UTC, and a preset like - "yesterday" is the caller's to compute. The hourly grain leaves a whole-hour offset - possible later without a schema change; a half-hour zone would need one. +- **A CSV of a report, from the API itself.** `GET /api/clicks.csv` streams the click log + only. A summary or a breakdown is one small answer already, and turning one into a file is + the web interface's own doing, client-side, not a route the API streams. +- **A time zone, on the API.** Every window and retention period it takes is UTC, and so is a + chart's bucket size — `offset` only moves where a day bucket begins, in whole hours, and does + nothing for an hourly one. The web interface computes a preset like "yesterday" from the + browser's own zone; a caller of the API directly still has to — see + `packages/ui/src/window/range.ts`. - **Notifications of any kind.** No mail configuration exists; `GET /api/alerts` is what an operator reads instead. - **Most link settings in the CLI.** `link add` takes `--target`, `--backup`, `--cap`, `--expires`, `--no-passthrough` and `--action` only, and no command changes a link after `link add`. `settings set` sets the install-wide traffic actions, the safe URL, the abuser threshold and the two retention periods. Device URLs, a returning URL, - country rules, a name, a password and the disabled state are the admin API's, or - hand-written SQL without it. + country rules, a name, a password and the disabled state are the admin API's and the web + interface's, or hand-written SQL without it. A returning URL also needs HTTPS to do anything: its cookie is marked `Secure`, so a browser drops it over plain HTTP. - **Proxy/VPN detection beyond Tor exits, cloud providers' published ranges, and region @@ -161,6 +160,11 @@ packages/worker/ ships the spool into ClickHouse; runs migrations on boot; writer of the settings row that the CLI and the admin API share. packages/admin/ the admin API: sessions, API keys, TOTP, domain/link/settings CRUD, the reports, the click log and its CSV export. +packages/ui/ the web interface: React 19, Tailwind 4, a Vite build the admin + service serves from its own host and dist/. No colour in source, no + Radix overlay primitive, no runtime import from a service package, + one

per screen. Every time shown is the browser's own zone, except a + click's raw fields, which are labelled UTC. packages/cli/ `clickmonk migrate | domain | link add | settings | ipdata | admin | apikey`. Two commands here are deliberately not API routes: `admin create`, because nothing can authenticate before the account exists, and @@ -178,7 +182,9 @@ test/stack/ brings the whole stack up against a local certificate author certificate, a sign-in over HTTPS, a password-protected link answered end to end, a click driven through the whole stack and read back as a report, a log page and a CSV, and a retention - period lowered and then enforced. + period lowered and then enforced. `ui.test.ts` drives the web + interface itself in a real browser, on that same stack, and fails + on any content security policy violation on any screen. ``` Three properties of the product shape every change: @@ -200,6 +206,12 @@ pnpm build # required before the first `pnpm test` pnpm test ``` +**`pnpm test` is two suites, run one after the other: the service packages' own Vitest run, +then `packages/ui`'s** (`vitest run && pnpm --filter @clickmonk/ui test`). The interface's +suite has its own config and runs in `Australia/Adelaide`; see `packages/ui/vitest.config.ts` +and CONTRIBUTING.md. Neither talks to the browser suite in `test/stack/`, which is part of +the stack suites below. + **Build before the first test run, and after changing `core`, `db`, `ipdata`, `worker` or `admin`.** Packages import each other by name, which resolves to the *built* `dist/`, and `dist/` @@ -288,6 +300,13 @@ These hold from the first line of code, whatever the stack turns out to be: migration changes fresh installs only. Fix it forward in a new one. - **A redirect never waits on reporting.** Recording a click is not allowed to add latency to, or a failure mode to, sending the visitor on. +- **`packages/ui/src` writes no colour of its own.** A colour bypasses the measured brand + roles; use a semantic class or `var(--color-…)`. +- **`packages/ui` ships no Radix overlay primitive, no inline script or style, and no + runtime import from a service package.** The first two the content security policy + refuses outright; the third throws at load in a browser that has no Node behind it. +- **Every screen in `packages/ui` has exactly one `

`**, asserted by heading level, not + by its text. ## Writing tests here diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec5b299..59e5b94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,40 @@ You need Node 22, Docker with Compose v2, and `corepack` (it ships with Node). T `CLAUDE.md` explains why the build comes before the tests, and the order CI runs checks in. +### Working on the web interface + +`packages/ui` is a React app the admin service serves. From the repo root: + + pnpm --filter @clickmonk/ui test # unit suite + pnpm --filter @clickmonk/ui build # tsc -b, then the production build + +The unit suite runs in `Australia/Adelaide` on purpose — a zone that is not a whole number of +hours from UTC and observes daylight saving — so time logic that is only right in UTC fails +here. It runs as part of the root `pnpm test` too. + +The browser suite drives a real browser against the shipped stack over HTTPS, so it goes +through the stack suites (see "The stack suites" above), not `pnpm --filter @clickmonk/ui +test`. Set `CLICKMONK_SHOTS=1` to have that run also write a screenshot of every screen, in +both themes and at two widths, to `test/stack/tmp/e2e/shots/`. + +Rules the source-rule tests enforce, each with its own reason: + +- **No colour in `packages/ui/src`** — no hex, `rgb()`/`hsl()`/`oklch()`, named colour, or + Tailwind palette class. A colour that bypasses the measured brand roles can pass contrast in + one theme and fail in the other. Use a semantic class (`bg-primary`, `text-destructive`…) or, + in SVG, `var(--color-…)`. +- **No Radix overlay primitive** (`Dialog`, `Select`, `Popover`…) — they inject a ` diff --git a/packages/ui/public/theme.js b/packages/ui/public/theme.js new file mode 100644 index 0000000..cf8abde --- /dev/null +++ b/packages/ui/public/theme.js @@ -0,0 +1,8 @@ +// Applies the stored theme before first paint, so a dark choice does not flash +// light for a frame. The key is the one ThemeToggle writes. Guarded, because a +// browser that refuses storage throws on access, and a theme is not worth a +// blank page. +try { + var t = localStorage.getItem('cm-theme') + if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t) +} catch (e) {} diff --git a/packages/ui/src/App.test.tsx b/packages/ui/src/App.test.tsx new file mode 100644 index 0000000..c0e67ab --- /dev/null +++ b/packages/ui/src/App.test.tsx @@ -0,0 +1,396 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import { describe, expect, it, vi } from 'vitest' +import { App } from './App' +import { createClient } from './api/client' +import { createScheduler } from './api/scheduler' + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +const ME = { + email: 'admin@example.com', + totpEnabled: false, + recoveryCodesLeft: 0, + failedLogins: 0, + lockedUntil: null, + credential: 'session', +} +const STATUS = { newestHour: null, reporting: 'ok', ipData: null, ipDataProblem: null, alerts: 0 } + +/** Answers by path; a path the test did not name answers 500 so nothing is silently faked. */ +function server(routes: Record Response | Promise>) { + const calls: string[] = [] + const fetchImpl = vi.fn((url: RequestInfo | URL, init?: RequestInit) => { + const path = String(url).split('?')[0] ?? '' + calls.push(`${init?.method ?? 'GET'} ${path}`) + const route = routes[`${init?.method ?? 'GET'} ${path}`] + return Promise.resolve( + route ? route(init ?? {}) : json(500, { error: 'internal', message: 'unrouted' }), + ) + }) + const makeClient = (onUnauthorized: () => void) => + createClient({ + fetchImpl: fetchImpl as unknown as typeof fetch, + scheduler: createScheduler({ sleep: () => Promise.resolve() }), + onUnauthorized, + }) + return { calls, makeClient } +} + +const show = ( + makeClient: (u: () => void) => ReturnType, + at = '/overview', + bootTimeoutMs = 8000, +) => + render( + + + , + ) + +describe('what the operator sees first', () => { + it('is the application when the session is good', async () => { + const s = server({ + 'GET /api/me': () => json(200, ME), + 'GET /api/status': () => json(200, STATUS), + }) + show(s.makeClient) + expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument() + }) + + it('is the sign-in screen when there is no session, and says nothing about one ending', async () => { + const s = server({ + 'GET /api/me': () => json(401, { error: 'unauthenticated', message: 'sign in' }), + }) + show(s.makeClient) + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument() + }) + + it('says how to create the account when there is none', async () => { + const s = server({ + 'GET /api/me': () => + json(503, { error: 'no_admin', message: 'this install has no admin account yet' }), + }) + show(s.makeClient) + expect( + await screen.findByRole('heading', { level: 1, name: 'ClickMonk has no admin account yet' }), + ).toBeInTheDocument() + expect(screen.getByText(/admin create you@example.com/)).toBeInTheDocument() + }) + + // An unreachable service is not a reason to ask for a password: the operator + // would type one, watch it fail too, and still not know why. + it('says the service is not responding rather than asking for a password, and tries again', async () => { + let up = false + const s = server({ + 'GET /api/me': () => (up ? json(200, ME) : json(502, 'Bad Gateway')), + 'GET /api/status': () => json(200, STATUS), + }) + show(s.makeClient) + expect( + await screen.findByRole('heading', { level: 1, name: 'ClickMonk is not responding' }), + ).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Sign in' })).not.toBeInTheDocument() + up = true + await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument() + }) + + it('stops waiting for a check that never answers', async () => { + const hang = (init: RequestInit) => + new Promise((_, reject) => + init.signal?.addEventListener('abort', () => + reject(new DOMException('timed out', 'TimeoutError')), + ), + ) + const s = server({ 'GET /api/me': hang }) + show(s.makeClient, '/overview', 20) + expect( + await screen.findByRole('heading', { level: 1, name: 'ClickMonk is not responding' }), + ).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent( + 'ClickMonk took too long to answer. Try again.', + ) + }) + + // The client calls `onUnauthorized` for any 401 other than the sign-in + // route's own — including, in principle, one from a request the boot check + // did not itself make. Caught here by calling the captured callback + // directly while the boot check is still pending, rather than waiting for + // it to answer: nothing had been signed in yet, so this must not be shown + // as a session that ended. + it('a stray unauthorized signal while checking does not end a session nobody had', async () => { + let capturedOnUnauthorized: (() => void) | undefined + const hang = () => new Promise(() => {}) + const s = server({ 'GET /api/me': hang }) + const makeClient = (onUnauthorized: () => void) => { + capturedOnUnauthorized = onUnauthorized + return s.makeClient(onUnauthorized) + } + show(makeClient, '/overview', 8000) + await act(async () => { + capturedOnUnauthorized?.() + }) + expect(screen.queryByRole('heading', { level: 1, name: 'Sign in' })).not.toBeInTheDocument() + expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument() + }) +}) + +describe('a session that ends while the application is open', () => { + // The session ends between the boot check and the first screen's requests: + // every one of them answers 401. The operator sees the sign-in screen once, + // with the reason, and no error. + it('goes to the sign-in screen once, says why, and asks nothing more', async () => { + const s = server({ + 'GET /api/me': () => json(200, ME), + 'GET /api/status': () => json(401, { error: 'unauthenticated', message: 'sign in' }), + }) + show(s.makeClient) + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Your session ended. Sign in again.') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(s.calls.filter((c) => c === 'GET /api/me')).toHaveLength(1) + }) + + // The header's own status check alone is 401 here, which already ends the + // session (as the case above shows) before the overview's own ten report + // requests could possibly matter; naming all four refused routes is a + // shield against a mount order change, not proof that all four fired. + it('goes to the sign-in screen once when every request on the overview is refused', async () => { + const refused = () => json(401, { error: 'unauthenticated', message: 'sign in' }) + const s = server({ + 'GET /api/me': () => json(200, ME), + 'GET /api/status': refused, + 'GET /api/reports/summary': refused, + 'GET /api/reports/timeseries': refused, + 'GET /api/reports/breakdown': refused, + }) + show(s.makeClient, '/overview') + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Your session ended. Sign in again.') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(s.calls.filter((c) => c === 'GET /api/me')).toHaveLength(1) + }) +}) + +describe('signing in and out', () => { + it('signs in, checks the session, and shows the application', async () => { + let signedIn = false + const s = server({ + 'GET /api/me': () => + signedIn ? json(200, ME) : json(401, { error: 'unauthenticated', message: 'sign in' }), + 'POST /api/session': () => { + signedIn = true + return json(200, { ok: true, expiresAt: '2026-10-24T00:00:00.000Z' }) + }, + 'GET /api/status': () => json(200, STATUS), + }) + show(s.makeClient) + const user = userEvent.setup() + await user.type(await screen.findByLabelText('Email'), 'admin@example.com') + await user.type(screen.getByLabelText('Password'), 'a decent admin password') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument() + }) + + it('signs out and shows the sign-in screen, without saying the session ended', async () => { + const s = server({ + 'GET /api/me': () => json(200, ME), + 'GET /api/status': () => json(200, STATUS), + 'DELETE /api/session': () => json(200, { ok: true }), + }) + show(s.makeClient) + await userEvent.setup().click(await screen.findByRole('button', { name: 'Sign out' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + expect(s.calls).toContain('DELETE /api/session') + expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument() + }) + + // The check that follows a successful sign-in reuses the same "checking" + // phase as the boot check, which unmounts the sign-in screen (and the + // password sitting in its state) for as long as that check is in flight. + it('leaves the sign-in screen while the check after signing in is pending', async () => { + let meCalls = 0 + let resolveSecondMe: ((r: Response) => void) | undefined + const s = server({ + 'GET /api/me': () => { + meCalls += 1 + if (meCalls === 1) return json(401, { error: 'unauthenticated', message: 'sign in' }) + return new Promise((resolve) => { + resolveSecondMe = resolve + }) + }, + 'POST /api/session': () => json(200, { ok: true, expiresAt: '2026-10-24T00:00:00.000Z' }), + }) + show(s.makeClient) + const user = userEvent.setup() + await user.type(await screen.findByLabelText('Email'), 'admin@example.com') + await user.type(screen.getByLabelText('Password'), 'a decent admin password') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + await waitFor(() => expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()) + resolveSecondMe?.(json(200, ME)) + expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument() + }) +}) + +describe('a session that does not stick after signing in', () => { + // The service accepted the password (the POST answers 200), but the check + // that follows still gets a 401: the browser refused the cookie, not the + // credentials. Distinct wording from "Your session ended" — nothing here + // had ever been signed in for a session to end. + it('says the browser did not keep the session, distinct from one ending', async () => { + const s = server({ + 'GET /api/me': () => json(401, { error: 'unauthenticated', message: 'sign in' }), + 'POST /api/session': () => json(200, { ok: true, expiresAt: '2026-10-24T00:00:00.000Z' }), + }) + show(s.makeClient) + const user = userEvent.setup() + await user.type(await screen.findByLabelText('Email'), 'admin@example.com') + await user.type(screen.getByLabelText('Password'), 'a decent admin password') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent( + 'Signed in, but this browser did not keep the session. ClickMonk has to be opened over https.', + ) + expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument() + }) +}) + +describe('refreshing the account after a change', () => { + // `refreshMe` exists to show what the service now says, not what it said + // at boot — so a mutation that calls `GET /api/me` and never stores the + // answer must fail here. The second response differs from the first + // (`totpEnabled: true`, a fresh `recoveryCodesLeft`), so the assertion can + // only pass if that second response is the one the screen shows. + it("shows the second me response's state after confirming two-factor, not the first", async () => { + let meCalls = 0 + const s = server({ + 'GET /api/me': () => { + meCalls += 1 + return meCalls === 1 + ? json(200, ME) + : json(200, { ...ME, totpEnabled: true, recoveryCodesLeft: 10 }) + }, + 'GET /api/status': () => json(200, STATUS), + 'GET /api/sessions': () => json(200, { sessions: [] }), + 'GET /api/keys': () => json(200, { keys: [], truncated: false }), + 'POST /api/totp': () => + json(200, { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP' }), + 'POST /api/totp/confirm': () => json(200, { ok: true, recoveryCodes: ['ABCDE-FGHJK'] }), + }) + show(s.makeClient, '/account') + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: 'Set up an authenticator app' })) + await user.type(screen.getByLabelText('Your password'), 'a decent admin password') + await user.click(screen.getByRole('button', { name: 'Continue' })) + await user.type(await screen.findByLabelText('Code from the app'), '123456') + await user.click(screen.getByRole('button', { name: 'Turn on two-factor' })) + expect(await screen.findByText('On. 10 recovery codes left.')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Set up an authenticator app' }), + ).not.toBeInTheDocument() + }) + + // The account screen calls its refresh without awaiting it + // (`onChanged()`, not `await onChanged()`), so a rejection this refresh + // does not catch would be an unhandled one. Here the refresh's own + // `GET /api/me` is what answers 401 — the same request `ended` already + // routes to the sign-in screen through `onUnauthorized` — so the fix is + // exercised on the most common path: a change that outlives the session it + // was made under. + it('signs out instead of crashing when the refresh after a change finds the session gone', async () => { + let meCalls = 0 + const s = server({ + 'GET /api/me': () => { + meCalls += 1 + return meCalls === 1 + ? json(200, ME) + : json(401, { error: 'unauthenticated', message: 'sign in' }) + }, + 'GET /api/status': () => json(200, STATUS), + 'GET /api/sessions': () => json(200, { sessions: [] }), + 'GET /api/keys': () => json(200, { keys: [], truncated: false }), + 'POST /api/totp': () => + json(200, { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP' }), + 'POST /api/totp/confirm': () => json(200, { ok: true, recoveryCodes: ['ABCDE-FGHJK'] }), + }) + show(s.makeClient, '/account') + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: 'Set up an authenticator app' })) + await user.type(screen.getByLabelText('Your password'), 'a decent admin password') + await user.click(screen.getByRole('button', { name: 'Continue' })) + await user.type(await screen.findByLabelText('Code from the app'), '123456') + await user.click(screen.getByRole('button', { name: 'Turn on two-factor' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Your session ended. Sign in again.') + }) + + // A refresh that was already in flight when the operator signed out + // answers after `signOut` has already moved the phase to `anonymous`. + // Setting `authenticated` unconditionally on that answer would put the + // application back on a screen for a session that no longer exists. + it('does not let a refresh already in flight undo a sign-out that finished while it waited', async () => { + let meCalls = 0 + let resolveSecondMe: ((r: Response) => void) | undefined + const s = server({ + 'GET /api/me': () => { + meCalls += 1 + if (meCalls === 1) return json(200, ME) + return new Promise((resolve) => { + resolveSecondMe = resolve + }) + }, + 'GET /api/status': () => json(200, STATUS), + 'GET /api/sessions': () => json(200, { sessions: [] }), + 'GET /api/keys': () => json(200, { keys: [], truncated: false }), + 'POST /api/totp': () => + json(200, { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP' }), + 'POST /api/totp/confirm': () => json(200, { ok: true, recoveryCodes: ['ABCDE-FGHJK'] }), + 'DELETE /api/session': () => json(200, { ok: true }), + }) + show(s.makeClient, '/account') + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: 'Set up an authenticator app' })) + await user.type(screen.getByLabelText('Your password'), 'a decent admin password') + await user.click(screen.getByRole('button', { name: 'Continue' })) + await user.type(await screen.findByLabelText('Code from the app'), '123456') + await user.click(screen.getByRole('button', { name: 'Turn on two-factor' })) + // The refresh's own GET /api/me — the second call — is now pending. + await waitFor(() => expect(meCalls).toBe(2)) + await user.click(screen.getByRole('button', { name: 'Sign out' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + await act(async () => { + resolveSecondMe?.(json(200, ME)) + await Promise.resolve() + }) + expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument() + }) +}) + +describe('an address with nothing at it', () => { + it('says so, as a screen of its own', async () => { + const s = server({ + 'GET /api/me': () => json(200, ME), + 'GET /api/status': () => json(200, STATUS), + }) + show(s.makeClient, '/no-such-screen') + expect( + await screen.findByRole('heading', { level: 1, name: 'Nothing here' }), + ).toBeInTheDocument() + }) + + it('sends the root to the overview', async () => { + const s = server({ + 'GET /api/me': () => json(200, ME), + 'GET /api/status': () => json(200, STATUS), + }) + show(s.makeClient, '/') + await waitFor(() => + expect(screen.getByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument(), + ) + }) +}) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx new file mode 100644 index 0000000..ca3e853 --- /dev/null +++ b/packages/ui/src/App.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { type ApiClient, createClient } from './api/client' +import { ClientProvider } from './api/context' +import { ApiError } from './api/errors' +import type { Me } from './api/types' +import { AppRoutes } from './app/Router' +import { Shell } from './app/Shell' +import { MeProvider } from './app/me' +import { RefreshProvider } from './app/refresh' +import { NoAdmin } from './screens/NoAdmin' +import { SignIn } from './screens/SignIn' +import { Unavailable } from './screens/Unavailable' + +type Phase = + | { name: 'checking' } + | { name: 'unavailable'; error: ApiError } + | { name: 'no_admin' } + | { name: 'anonymous'; ended: boolean; notKept: boolean } + | { name: 'authenticated'; me: Me } + +export const BOOT_TIMEOUT_MS = 8000 + +/** + * What the operator sees before any screen. One client for the life of the + * page, whose `onUnauthorized` is the only way a 401 changes the phase: a + * session that ended is a state of the whole interface, not an error in one + * panel, and the second and later 401s find the phase already anonymous. + */ +export function App({ + makeClient = (onUnauthorized) => createClient({ onUnauthorized }), + bootTimeoutMs = BOOT_TIMEOUT_MS, +}: { makeClient?: (onUnauthorized: () => void) => ApiClient; bootTimeoutMs?: number }) { + const [phase, setPhase] = useState({ name: 'checking' }) + const ended = useCallback(() => { + setPhase((p) => + p.name === 'authenticated' ? { name: 'anonymous', ended: true, notKept: false } : p, + ) + }, []) + // biome-ignore lint/correctness/useExhaustiveDependencies: one client for the life of the page + const client = useMemo(() => makeClient(ended), []) + + // The same request, run at boot and again right after a sign-in. Only the + // second case can mean the browser refused to keep the session cookie (an + // `https`-only cookie handed to a plain `http` page): the boot case, and a + // retry from Unavailable, get no special wording because nothing was just + // signed in for the session to have failed to keep. + const runCheck = useCallback( + async (unauthenticatedNotice: 'none' | 'not_kept') => { + setPhase({ name: 'checking' }) + try { + const me = await client.me({ signal: AbortSignal.timeout(bootTimeoutMs) }) + setPhase({ name: 'authenticated', me }) + } catch (err) { + if (!(err instanceof ApiError)) throw err + if (err.status === 401) + setPhase({ + name: 'anonymous', + ended: false, + notKept: unauthenticatedNotice === 'not_kept', + }) + else if (err.status === 503 && err.code === 'no_admin') setPhase({ name: 'no_admin' }) + else setPhase({ name: 'unavailable', error: err }) + } + }, + [client, bootTimeoutMs], + ) + const check = useCallback(() => runCheck('none'), [runCheck]) + const afterSignIn = useCallback(() => runCheck('not_kept'), [runCheck]) + + useEffect(() => { + void check() + }, [check]) + + const signOut = useCallback(async () => { + try { + await client.signOut() + } catch { + // Signed out either way: a session the service already ended is not a reason to stay. + } + setPhase({ name: 'anonymous', ended: false, notKept: false }) + }, [client]) + + // Re-reads the account after a change on the account screen (enrolling or + // turning off two-factor, replacing recovery codes) so that screen shows + // what the service now says, not what it said at boot. Called without being + // awaited (`onChanged()`, not `await onChanged()`, on the account screen), + // so it must never reject: a 401 here already routed to `ended` through + // `onUnauthorized`, which moved the phase to `anonymous` itself, and this + // only needs to leave that alone rather than set `authenticated` over it. + // Any other failure is likewise swallowed — the next screen load surfaces + // it — because nothing downstream awaits this promise to catch it. + const refreshMe = useCallback(async () => { + try { + const me = await client.me() + setPhase((p) => (p.name === 'authenticated' ? { name: 'authenticated', me } : p)) + } catch { + // See above: a 401 already moved the phase; anything else is dropped. + } + }, [client]) + + return ( + + {phase.name === 'checking' &&
} + {phase.name === 'unavailable' && } + {phase.name === 'no_admin' && } + {phase.name === 'anonymous' && ( + + )} + {phase.name === 'authenticated' && ( + + + + + + + + )} + + ) +} diff --git a/packages/ui/src/api/client.test.ts b/packages/ui/src/api/client.test.ts new file mode 100644 index 0000000..dd7a70a --- /dev/null +++ b/packages/ui/src/api/client.test.ts @@ -0,0 +1,586 @@ +import { describe, expect, it, vi } from 'vitest' +import { type ApiClient, ApiError, createClient } from './client' +import { createScheduler } from './scheduler' + +const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) + +function setup(respond: (url: string, init: RequestInit) => Response | Promise) { + const fetchImpl = vi.fn((url: RequestInfo | URL, init?: RequestInit) => + Promise.resolve(respond(String(url), init ?? {})), + ) + const onUnauthorized = vi.fn() + const client = createClient({ + fetchImpl: fetchImpl as unknown as typeof fetch, + scheduler: createScheduler({ sleep: () => Promise.resolve() }), + onUnauthorized, + }) + return { client, fetchImpl, onUnauthorized } +} + +const W = { from: '2026-09-24T00:00:00.000Z', to: '2026-09-25T00:00:00.000Z' } + +describe('reads', () => { + it('asks for a summary with exactly the window it was given', async () => { + const { client, fetchImpl } = setup(() => json(200, { clicks: 1 })) + await client.summary(W) + expect(fetchImpl.mock.calls).toEqual([ + [ + '/api/reports/summary?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z', + { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } }, + ], + ]) + }) + + // An offset of zero is a value, not an absence. A query builder that drops + // falsy values sends no offset for UTC and would be right by accident; one + // that drops it for a zone at +0 would be wrong the day the default changed. + it('sends an offset of zero and a link when it has one', async () => { + const { client, fetchImpl } = setup(() => json(200, { buckets: [] })) + await client.timeseries({ ...W, link: '00000000-0000-4000-8000-0000000000a1' }, 'day', 0) + expect(fetchImpl.mock.calls[0]?.[0]).toBe( + '/api/reports/timeseries?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&link=00000000-0000-4000-8000-0000000000a1&bucket=day&offset=0', + ) + }) + + it('leaves out a filter nobody set', async () => { + const { client, fetchImpl } = setup(() => json(200, { clicks: [], nextCursor: null })) + await client.clicks({ ...W, class: undefined, country: 'DE' }, { limit: 50 }) + expect(fetchImpl.mock.calls[0]?.[0]).toBe( + '/api/clicks?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&country=DE&limit=50', + ) + }) + + it('reads a link list into a page', async () => { + const { client, fetchImpl } = setup(() => + json(200, { links: [{ id: 'a' }], nextCursor: '5.b' }), + ) + const page = await client.links({ q: '50% off', limit: 50 }) + expect(fetchImpl.mock.calls[0]?.[0]).toBe('/api/links?q=50%25+off&limit=50') + expect(page).toEqual({ items: [{ id: 'a' }], nextCursor: '5.b' }) + }) + + // The signal is what lets a screen the operator left cancel a request that + // was already sent, so it reaches the fetch itself, on a report as on a read. + it('hands the fetch the signal it was given', async () => { + const { client, fetchImpl } = setup(() => json(200, {})) + const left = new AbortController() + await client.summary(W, left.signal) + await client.link('00000000-0000-4000-8000-0000000000a1', { signal: left.signal }) + expect(fetchImpl.mock.calls.map((c) => c[1])).toEqual([ + { + method: 'GET', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + signal: left.signal, + }, + { + method: 'GET', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + signal: left.signal, + }, + ]) + }) + + it('builds the export’s address and fetches nothing', () => { + const { client, fetchImpl } = setup(() => json(200, {})) + expect(client.exportUrl({ ...W, outcome: 'blocked' })).toBe( + '/api/clicks.csv?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&outcome=blocked', + ) + expect(fetchImpl).not.toHaveBeenCalled() + }) +}) + +describe('writes', () => { + it('sends a body as JSON, with nothing added to it', async () => { + const { client, fetchImpl } = setup(() => json(200, { id: 'l1' })) + await client.updateLink('l1', { name: 'Spring' }) + expect(fetchImpl.mock.calls).toEqual([ + [ + '/api/links/l1', + { + method: 'PATCH', + credentials: 'same-origin', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: '{"name":"Spring"}', + }, + ], + ]) + }) + + it('sends a body with a DELETE when the route needs one', async () => { + const { client, fetchImpl } = setup(() => json(200, { ok: true })) + await client.disableTotp({ password: 'pw', code: '123456' }) + expect(fetchImpl.mock.calls[0]?.[1]).toEqual({ + method: 'DELETE', + credentials: 'same-origin', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: '{"password":"pw","code":"123456"}', + }) + }) + + it('escapes an id it puts in a path', async () => { + const { client, fetchImpl } = setup(() => json(200, { ok: true })) + await client.deleteLink('../settings') + expect(fetchImpl.mock.calls[0]?.[0]).toBe('/api/links/..%2Fsettings') + }) +}) + +describe('failures', () => { + it('turns the service’s refusal into an error that carries its code, message and wait', async () => { + const { client } = setup(() => + json( + 429, + { error: 'too_many_attempts', message: 'try again later' }, + { 'retry-after': '120' }, + ), + ) + const err = await client.signIn({ email: 'admin@example.com', password: 'x' }).catch((e) => e) + expect(err).toBeInstanceOf(ApiError) + expect([err.status, err.code, err.message, err.retryAfterSeconds]).toEqual([ + 429, + 'too_many_attempts', + 'try again later', + 120, + ]) + }) + + // A proxy in front of the service answers HTML. The status is the fact; a + // JSON parse error must not replace it. + it('keeps the status when the body is not the service’s', async () => { + const { client } = setup(() => new Response('Bad Gateway', { status: 502 })) + const err = await client.domains().catch((e) => e) + expect([err.status, err.code, err.message]).toEqual([ + 502, + 'unknown', + 'the service answered 502', + ]) + }) + + it('says the service did not answer when there was no response', async () => { + const { client } = setup(() => Promise.reject(new TypeError('Failed to fetch'))) + const err = await client.domains().catch((e) => e) + expect([err.status, err.code]).toEqual([0, 'unreachable']) + }) + + it('says a request ran out of time', async () => { + const { client } = setup(() => Promise.reject(new DOMException('timed out', 'TimeoutError'))) + const err = await client.me({ signal: AbortSignal.timeout(1) }).catch((e) => e) + expect([err.status, err.code]).toEqual([0, 'timeout']) + }) + + it('passes an abandoned request’s abort through untouched', async () => { + const { client } = setup(() => Promise.reject(new DOMException('aborted', 'AbortError'))) + const err = await client.domains().catch((e) => e) + expect(err).not.toBeInstanceOf(ApiError) + expect(err.name).toBe('AbortError') + }) + + it('says a session has ended, once per refusal', async () => { + const { client, onUnauthorized } = setup(() => + json(401, { error: 'unauthenticated', message: 'sign in' }), + ) + await client.links({}).catch(() => {}) + await client.summary(W).catch(() => {}) + expect(onUnauthorized).toHaveBeenCalledTimes(2) + }) + + // A wrong password, or a sign-in that needs the second factor, is a 401 from + // the sign-in route itself. That is the form's answer, not a session ending. + it('does not treat the sign-in route’s own refusal as a session ending', async () => { + const { client, onUnauthorized } = setup(() => + json(401, { error: 'totp_required', message: 'send the six-digit code' }), + ) + const err = await client.signIn({ email: 'admin@example.com', password: 'pw' }).catch((e) => e) + expect(err.code).toBe('totp_required') + expect(onUnauthorized).not.toHaveBeenCalled() + }) + + it('retries a busy report through the scheduler', async () => { + let n = 0 + const { client, fetchImpl } = setup(() => + ++n < 3 + ? json(429, { error: 'too_many_reports', message: 'busy' }, { 'retry-after': '1' }) + : json(200, { clicks: 7 }), + ) + expect(await client.summary(W)).toEqual({ clicks: 7 }) + expect(fetchImpl).toHaveBeenCalledTimes(3) + }) + + // Reports hold both slots and have not answered. A domain check is a write, + // not a report: it is sent at once rather than queued behind them, and its + // own 429 is shown rather than waited out. + it('does not queue or retry a busy write, which is not a report', async () => { + const { client, fetchImpl } = setup((url) => + url.startsWith('/api/reports/') + ? new Promise(() => {}) + : json(429, { error: 'too_many_checks', message: 'busy' }, { 'retry-after': '1' }), + ) + void client.summary(W) + void client.summary(W) + const check = client.checkDomain('d1').catch((e) => e) + expect(fetchImpl.mock.calls.map((c) => String(c[0]))).toEqual([ + '/api/reports/summary?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z', + '/api/reports/summary?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z', + '/api/domains/d1/check', + ]) + expect((await check).code).toBe('too_many_checks') + expect(fetchImpl).toHaveBeenCalledTimes(3) + }) +}) + +// Every method, one row each: the exact address and request it sends, and what +// it hands back from what the service answered. A method added to the client +// without a row here fails the first test below. +const READ = { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } } +const write = (method: string, body?: string) => + body === undefined + ? { method, credentials: 'same-origin', headers: { accept: 'application/json' } } + : { + method, + credentials: 'same-origin', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body, + } + +const LINK = '00000000-0000-4000-8000-0000000000a1' +const DOMAIN = '00000000-0000-4000-8000-0000000000d1' +const WINDOW_QS = 'from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z' + +type Row = { + call: (c: ApiClient) => Promise + url: string + init: unknown + answer: unknown + result: unknown +} + +const ROWS: Record, Row> = { + me: { + call: (c) => c.me(), + url: '/api/me', + init: READ, + answer: { + email: 'admin@example.com', + totpEnabled: false, + recoveryCodesLeft: 0, + credential: 'session', + }, + result: { + email: 'admin@example.com', + totpEnabled: false, + recoveryCodesLeft: 0, + credential: 'session', + }, + }, + signIn: { + call: (c) => c.signIn({ email: 'admin@example.com', password: 'pw', code: '123456' }), + url: '/api/session', + init: write('POST', '{"email":"admin@example.com","password":"pw","code":"123456"}'), + answer: { ok: true, expiresAt: '2026-10-24T10:00:00.000Z' }, + result: { ok: true, expiresAt: '2026-10-24T10:00:00.000Z' }, + }, + signOut: { + call: (c) => c.signOut(), + url: '/api/session', + init: write('DELETE'), + answer: { ok: true }, + result: { ok: true }, + }, + sessions: { + call: (c) => c.sessions(), + url: '/api/sessions', + init: READ, + answer: { sessions: [{ id: 's1', current: true }] }, + result: [{ id: 's1', current: true }], + }, + revokeSession: { + call: (c) => c.revokeSession('s/1'), + url: '/api/sessions/s%2F1', + init: write('DELETE'), + answer: { ok: true }, + result: { ok: true }, + }, + changePassword: { + call: (c) => c.changePassword({ currentPassword: 'old', newPassword: 'a new password' }), + url: '/api/password', + init: write('POST', '{"currentPassword":"old","newPassword":"a new password"}'), + answer: { ok: true, otherSessionsSignedOut: 2 }, + result: { ok: true, otherSessionsSignedOut: 2 }, + }, + startTotp: { + call: (c) => c.startTotp({ password: 'pw' }), + url: '/api/totp', + init: write('POST', '{"password":"pw"}'), + answer: { secret: 'ABCDEFGH', uri: 'otpauth://totp/x?secret=ABCDEFGH' }, + result: { secret: 'ABCDEFGH', uri: 'otpauth://totp/x?secret=ABCDEFGH' }, + }, + confirmTotp: { + call: (c) => c.confirmTotp({ password: 'pw', code: '654321' }), + url: '/api/totp/confirm', + init: write('POST', '{"password":"pw","code":"654321"}'), + answer: { ok: true, recoveryCodes: ['aaaa-bbbb'] }, + result: { ok: true, recoveryCodes: ['aaaa-bbbb'] }, + }, + disableTotp: { + call: (c) => c.disableTotp({ password: 'pw', recoveryCode: 'aaaa-bbbb' }), + url: '/api/totp', + init: write('DELETE', '{"password":"pw","recoveryCode":"aaaa-bbbb"}'), + answer: { ok: true }, + result: { ok: true }, + }, + newRecoveryCodes: { + call: (c) => c.newRecoveryCodes({ password: 'pw', code: '111111' }), + url: '/api/totp/recovery-codes', + init: write('POST', '{"password":"pw","code":"111111"}'), + answer: { recoveryCodes: ['cccc-dddd'] }, + result: { recoveryCodes: ['cccc-dddd'] }, + }, + keys: { + call: (c) => c.keys(), + url: '/api/keys', + init: READ, + answer: { keys: [{ id: 'k1' }], truncated: false }, + result: { keys: [{ id: 'k1' }], truncated: false }, + }, + createKey: { + call: (c) => c.createKey({ name: 'backup script', expiresDays: 30 }), + url: '/api/keys', + init: write('POST', '{"name":"backup script","expiresDays":30}'), + answer: { id: 'k2', name: 'backup script', key: 'made-up-key', expiresAt: null }, + result: { id: 'k2', name: 'backup script', key: 'made-up-key', expiresAt: null }, + }, + revokeKey: { + call: (c) => c.revokeKey('k2'), + url: '/api/keys/k2', + init: write('DELETE'), + answer: { ok: true }, + result: { ok: true }, + }, + domains: { + call: (c) => c.domains(), + url: '/api/domains', + init: READ, + answer: { domains: [{ id: DOMAIN }], truncated: true }, + result: { domains: [{ id: DOMAIN }], truncated: true }, + }, + addDomain: { + call: (c) => c.addDomain({ host: 'go.example.com', rootUrl: null }), + url: '/api/domains', + init: write('POST', '{"host":"go.example.com","rootUrl":null}'), + answer: { id: DOMAIN, host: 'go.example.com' }, + result: { id: DOMAIN, host: 'go.example.com' }, + }, + updateDomain: { + call: (c) => c.updateDomain(DOMAIN, { notFoundUrl: 'https://example.com/missing' }), + url: `/api/domains/${DOMAIN}`, + init: write('PATCH', '{"notFoundUrl":"https://example.com/missing"}'), + answer: { id: DOMAIN, notFoundUrl: 'https://example.com/missing' }, + result: { id: DOMAIN, notFoundUrl: 'https://example.com/missing' }, + }, + deleteDomain: { + call: (c) => c.deleteDomain(DOMAIN), + url: `/api/domains/${DOMAIN}`, + init: write('DELETE'), + answer: { ok: true }, + result: { ok: true }, + }, + checkDomain: { + call: (c) => c.checkDomain(DOMAIN), + url: `/api/domains/${DOMAIN}/check`, + init: write('POST'), + answer: { status: 'missing_token', detail: null }, + result: { status: 'missing_token', detail: null }, + }, + unverifyDomain: { + call: (c) => c.unverifyDomain(DOMAIN), + url: `/api/domains/${DOMAIN}/unverify`, + init: write('POST'), + answer: { ok: true, note: 'links on this domain now answer 404' }, + result: { ok: true, note: 'links on this domain now answer 404' }, + }, + links: { + call: (c) => c.links({ domain: 'go.example.com', q: 'spring', limit: 20, cursor: '5.b' }), + url: '/api/links?domain=go.example.com&q=spring&limit=20&cursor=5.b', + init: READ, + answer: { links: [{ id: LINK }], nextCursor: null }, + result: { items: [{ id: LINK }], nextCursor: null }, + }, + link: { + call: (c) => c.link(LINK), + url: `/api/links/${LINK}`, + init: READ, + answer: { id: LINK, slug: 'spring' }, + result: { id: LINK, slug: 'spring' }, + }, + createLink: { + call: (c) => + c.createLink({ + host: 'go.example.com', + slug: 'spring', + targets: [{ url: 'https://example.com/a' }], + }), + url: '/api/links', + init: write( + 'POST', + '{"host":"go.example.com","slug":"spring","targets":[{"url":"https://example.com/a"}]}', + ), + answer: { id: LINK, slug: 'spring' }, + result: { id: LINK, slug: 'spring' }, + }, + updateLink: { + call: (c) => c.updateLink(LINK, { enabled: false, password: null }), + url: `/api/links/${LINK}`, + init: write('PATCH', '{"enabled":false,"password":null}'), + answer: { id: LINK, enabled: false }, + result: { id: LINK, enabled: false }, + }, + deleteLink: { + call: (c) => c.deleteLink(LINK), + url: `/api/links/${LINK}`, + init: write('DELETE'), + answer: { ok: true }, + result: { ok: true }, + }, + settings: { + call: (c) => c.settings(), + url: '/api/settings', + init: READ, + answer: { retention: null, note: null, problem: 'the settings row is missing' }, + result: { retention: null, note: null, problem: 'the settings row is missing' }, + }, + putSettings: { + call: (c) => + c.putSettings({ + traffic: { + actions: { bot: 'flag', abuser: 'block', anonymous: 'nothing', datacenter: 'safe' }, + safeUrl: 'https://example.com/safe', + abuserThreshold: 60, + }, + retention: { rawRetentionDays: 90, ipRetentionDays: null }, + }), + url: '/api/settings', + init: write( + 'PUT', + '{"traffic":{"actions":{"bot":"flag","abuser":"block","anonymous":"nothing","datacenter":"safe"},"safeUrl":"https://example.com/safe","abuserThreshold":60},"retention":{"rawRetentionDays":90,"ipRetentionDays":null}}', + ), + answer: { note: 'raw clicks are kept 90 days', problem: null }, + result: { note: 'raw clicks are kept 90 days', problem: null }, + }, + summary: { + call: (c) => c.summary({ ...W, link: LINK }), + url: `/api/reports/summary?${WINDOW_QS}&link=${LINK}`, + init: READ, + answer: { clicks: 3, visitors: 2 }, + result: { clicks: 3, visitors: 2 }, + }, + timeseries: { + call: (c) => c.timeseries(W, 'day', 10), + url: `/api/reports/timeseries?${WINDOW_QS}&bucket=day&offset=10`, + init: READ, + answer: { bucket: 'day', buckets: [] }, + result: { bucket: 'day', buckets: [] }, + }, + breakdown: { + call: (c) => c.breakdown(W, 'country', 10), + url: `/api/reports/breakdown?${WINDOW_QS}&dimension=country&limit=10`, + init: READ, + answer: { dimension: 'country', rows: [{ value: 'DE', clicks: 4, visitors: 3 }] }, + result: { dimension: 'country', rows: [{ value: 'DE', clicks: 4, visitors: 3 }] }, + }, + clicks: { + call: (c) => + c.clicks( + { ...W, link: LINK, class: 'bot', outcome: 'blocked', country: 'DE' }, + { limit: 25, cursor: '9.c' }, + ), + url: `/api/clicks?${WINDOW_QS}&link=${LINK}&class=bot&outcome=blocked&country=DE&limit=25&cursor=9.c`, + init: READ, + answer: { clicks: [], nextCursor: null }, + result: { clicks: [], nextCursor: null }, + }, + clickCount: { + call: (c) => c.clickCount({ ...W, class: 'human', outcome: 'target', country: 'FR' }), + url: `/api/clicks/count?${WINDOW_QS}&class=human&outcome=target&country=FR`, + init: READ, + answer: { count: 12, cap: 1000000, truncated: false }, + result: { count: 12, cap: 1000000, truncated: false }, + }, + status: { + call: (c) => c.status(), + url: '/api/status', + init: READ, + answer: { reporting: 'ok', alerts: 1 }, + result: { reporting: 'ok', alerts: 1 }, + }, +} + +describe('every method', () => { + it('has a row here, and only exportUrl is not a request', () => { + const { client } = setup(() => json(200, {})) + expect(Object.keys(client).sort()).toEqual([...Object.keys(ROWS), 'exportUrl'].sort()) + }) + + it.each(Object.entries(ROWS))( + '%s sends exactly its request and hands back its answer', + async (_name, row) => { + const { client, fetchImpl } = setup(() => json(200, row.answer)) + expect(await row.call(client)).toEqual(row.result) + expect(fetchImpl.mock.calls).toEqual([[row.url, row.init]]) + }, + ) +}) + +describe('the report queue', () => { + // Two summaries hold both slots and never answer. Every other report waits + // behind them rather than being sent. + it('holds status, breakdowns, the click log and its count behind the reports in flight', async () => { + const { client, fetchImpl } = setup(() => new Promise(() => {})) + void client.summary(W) + void client.summary(W) + void client.status() + void client.breakdown(W, 'country', 10) + void client.clicks(W, { limit: 25 }) + void client.clickCount(W) + void client.timeseries(W, 'day', 0) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchImpl.mock.calls.map((c) => String(c[0]))).toEqual([ + `/api/reports/summary?${WINDOW_QS}`, + `/api/reports/summary?${WINDOW_QS}`, + ]) + }) +}) + +describe('edges', () => { + it('treats a 401 on signing out as a session that ended', async () => { + const { client, onUnauthorized } = setup(() => + json(401, { error: 'unauthenticated', message: 'sign in' }), + ) + const err = await client.signOut().catch((e) => e) + expect(err.code).toBe('unauthenticated') + expect(onUnauthorized).toHaveBeenCalledTimes(1) + }) + + it('keeps the status when a success is not the service’s JSON', async () => { + const { client } = setup(() => new Response('Welcome to nginx', { status: 200 })) + const err = await client.domains().catch((e) => e) + expect(err).toBeInstanceOf(ApiError) + expect([err.status, err.code, err.message]).toEqual([ + 200, + 'unknown', + 'the service answered 200', + ]) + }) + + // An empty search box and an empty domain filter are no filter: the route + // refuses both as values. + it('sends no search and no domain for an empty one', async () => { + const { client, fetchImpl } = setup(() => json(200, { links: [], nextCursor: null })) + await client.links({ q: '', domain: '', limit: 50 }) + expect(fetchImpl.mock.calls[0]?.[0]).toBe('/api/links?limit=50') + }) +}) diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts new file mode 100644 index 0000000..2dad1ed --- /dev/null +++ b/packages/ui/src/api/client.ts @@ -0,0 +1,225 @@ +/** + * The only place this package calls `fetch`. + * + * Every request is same-origin with the cookie the browser holds; a write + * carries an `Origin` header because every browser adds one to a same-origin + * request that is not a GET, and the service requires exactly that. Nothing + * here sets it, and nothing here needs a token: the cookie is `SameSite=Strict` + * and the service answers no cross-origin request at all. + * + * Every failure becomes an `ApiError` with the service's own code and message, + * or a code of this module's when the service never said anything: `unknown` + * for a body that is not the service's (a proxy's error page), `unreachable` + * for no response, `timeout` for a request that ran out of time. An abort the + * interface caused is rethrown as it is, so that nothing shows an error for a + * request the operator navigated away from. + * + * Any 401 other than the sign-in route's own calls `onUnauthorized`: a session + * that ended is a state of the whole interface, not an error of one panel. + */ +import { ApiError } from './errors' +import { type Scheduler, createScheduler } from './scheduler' +import type { + ApiKey, + Breakdown, + ClickCount, + ClickFilters, + ClickPage, + Domain, + DomainCheck, + Link, + LinkInput, + LinkPatch, + Me, + NewApiKey, + Page, + Session, + Settings, + SettingsInput, + SignIn, + Status, + Summary, + Timeseries, + WindowQuery, +} from './types' + +export { REPORT_RETRIES, REPORTS_IN_FLIGHT } from './scheduler' + +export { ApiError } + +type Query = Record + +/** A query string from the values that are set. Zero and false are values. */ +function qs(q: Query): string { + const p = new URLSearchParams() + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null) p.append(k, String(v)) + const s = p.toString() + return s === '' ? '' : `?${s}` +} + +const id = (value: string): string => encodeURIComponent(value) + +async function errorOf(res: Response): Promise { + const retry = Number(res.headers.get('retry-after')) + const retryAfter = Number.isFinite(retry) && retry > 0 ? retry : undefined + try { + const body = (await res.json()) as { error?: unknown; message?: unknown } + if (typeof body.error === 'string' && typeof body.message === 'string') { + return new ApiError(res.status, body.error, body.message, retryAfter) + } + } catch { + // Not the service's JSON: fall through to the status alone. + } + return new ApiError(res.status, 'unknown', `the service answered ${res.status}`, retryAfter) +} + +export type ApiClient = ReturnType + +export function createClient( + o: { fetchImpl?: typeof fetch; scheduler?: Scheduler; onUnauthorized?: () => void } = {}, +) { + const fetchImpl = o.fetchImpl ?? ((...a: Parameters) => fetch(...a)) + const scheduler = o.scheduler ?? createScheduler() + + async function call( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + opts: { body?: unknown; signal?: AbortSignal } = {}, + ): Promise { + const init: RequestInit = { + method, + credentials: 'same-origin', + headers: + opts.body === undefined + ? { accept: 'application/json' } + : { accept: 'application/json', 'content-type': 'application/json' }, + ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }), + ...(opts.signal === undefined ? {} : { signal: opts.signal }), + } + let res: Response + try { + res = await fetchImpl(path, init) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') throw err + if (err instanceof DOMException && err.name === 'TimeoutError') { + throw new ApiError(0, 'timeout', 'ClickMonk took too long to answer') + } + throw new ApiError(0, 'unreachable', 'ClickMonk did not answer') + } + if (!res.ok) { + const err = await errorOf(res) + // Signing in is the one route whose 401 is the form's answer (a wrong + // password, a code needed) rather than a session that ended. Signing out + // is the same path with another method, and its 401 is a session ending. + if (res.status === 401 && !(method === 'POST' && path === '/api/session')) { + o.onUnauthorized?.() + } + throw err + } + try { + return (await res.json()) as T + } catch { + // A success that is not the service's JSON is a proxy's page, not an answer. + throw new ApiError(res.status, 'unknown', `the service answered ${res.status}`) + } + } + + const report = (path: string, signal?: AbortSignal): Promise => + scheduler.run(() => call('GET', path, { signal }), signal) + + const windowOf = (w: WindowQuery): Query => ({ from: w.from, to: w.to, link: w.link }) + const filtersOf = (f: ClickFilters): Query => ({ + ...windowOf(f), + class: f.class, + outcome: f.outcome, + country: f.country, + }) + + return { + // The account and its credentials. + me: (opts: { signal?: AbortSignal } = {}) => call('GET', '/api/me', opts), + signIn: (body: SignIn) => + call<{ ok: true; expiresAt: string }>('POST', '/api/session', { body }), + signOut: () => call<{ ok: true }>('DELETE', '/api/session'), + sessions: () => call<{ sessions: Session[] }>('GET', '/api/sessions').then((r) => r.sessions), + revokeSession: (sessionId: string) => + call<{ ok: true }>('DELETE', `/api/sessions/${id(sessionId)}`), + changePassword: (body: { currentPassword: string; newPassword: string }) => + call<{ ok: true; otherSessionsSignedOut: number }>('POST', '/api/password', { body }), + startTotp: (body: { password: string; code?: string; recoveryCode?: string }) => + call<{ secret: string; uri: string }>('POST', '/api/totp', { body }), + confirmTotp: (body: { password: string; code: string }) => + call<{ ok: true; recoveryCodes: string[] }>('POST', '/api/totp/confirm', { body }), + disableTotp: (body: { password: string; code?: string; recoveryCode?: string }) => + call<{ ok: true }>('DELETE', '/api/totp', { body }), + newRecoveryCodes: (body: { password: string; code?: string; recoveryCode?: string }) => + call<{ recoveryCodes: string[] }>('POST', '/api/totp/recovery-codes', { body }), + keys: () => call<{ keys: ApiKey[]; truncated: boolean }>('GET', '/api/keys'), + createKey: (body: { name: string; expiresDays?: number }) => + call('POST', '/api/keys', { body }), + revokeKey: (keyId: string) => call<{ ok: true }>('DELETE', `/api/keys/${id(keyId)}`), + + // Domains. + domains: () => call<{ domains: Domain[]; truncated: boolean }>('GET', '/api/domains'), + addDomain: (body: { host: string; rootUrl?: string | null; notFoundUrl?: string | null }) => + call('POST', '/api/domains', { body }), + updateDomain: ( + domainId: string, + body: { rootUrl?: string | null; notFoundUrl?: string | null }, + ) => call('PATCH', `/api/domains/${id(domainId)}`, { body }), + deleteDomain: (domainId: string) => + call<{ ok: true }>('DELETE', `/api/domains/${id(domainId)}`), + checkDomain: (domainId: string) => + call('POST', `/api/domains/${id(domainId)}/check`), + unverifyDomain: (domainId: string) => + call<{ ok: true; note: string }>('POST', `/api/domains/${id(domainId)}/unverify`), + + // Links. + links: async (q: { domain?: string; q?: string; limit?: number; cursor?: string }): Promise< + Page + > => { + // An empty search box is no search: the route refuses `q=` and `domain=` + // as values too short to mean anything. + const r = await call<{ links: Link[]; nextCursor: string | null }>( + 'GET', + `/api/links${qs({ domain: q.domain || undefined, q: q.q || undefined, limit: q.limit, cursor: q.cursor })}`, + ) + return { items: r.links, nextCursor: r.nextCursor } + }, + link: (linkId: string, opts: { signal?: AbortSignal } = {}) => + call('GET', `/api/links/${id(linkId)}`, opts), + createLink: (body: LinkInput) => call('POST', '/api/links', { body }), + updateLink: (linkId: string, body: LinkPatch) => + call('PATCH', `/api/links/${id(linkId)}`, { body }), + deleteLink: (linkId: string) => call<{ ok: true }>('DELETE', `/api/links/${id(linkId)}`), + + // Settings. + settings: () => call('GET', '/api/settings'), + putSettings: (body: SettingsInput) => call('PUT', '/api/settings', { body }), + + // Reports, through the scheduler. + summary: (w: WindowQuery, signal?: AbortSignal) => + report(`/api/reports/summary${qs(windowOf(w))}`, signal), + timeseries: (w: WindowQuery, bucket: 'hour' | 'day', offset: number, signal?: AbortSignal) => + report( + `/api/reports/timeseries${qs({ ...windowOf(w), bucket, offset })}`, + signal, + ), + breakdown: (w: WindowQuery, dimension: string, limit: number, signal?: AbortSignal) => + report( + `/api/reports/breakdown${qs({ ...windowOf(w), dimension, limit })}`, + signal, + ), + clicks: (f: ClickFilters, page: { limit: number; cursor?: string }, signal?: AbortSignal) => + report( + `/api/clicks${qs({ ...filtersOf(f), limit: page.limit, cursor: page.cursor })}`, + signal, + ), + clickCount: (f: ClickFilters, signal?: AbortSignal) => + report(`/api/clicks/count${qs(filtersOf(f))}`, signal), + status: (signal?: AbortSignal) => report('/api/status', signal), + + /** The export's address. A navigation, never a fetch: the browser streams it to disk. */ + exportUrl: (f: ClickFilters): string => `/api/clicks.csv${qs(filtersOf(f))}`, + } +} diff --git a/packages/ui/src/api/context.tsx b/packages/ui/src/api/context.tsx new file mode 100644 index 0000000..c3cdfc7 --- /dev/null +++ b/packages/ui/src/api/context.tsx @@ -0,0 +1,15 @@ +import { type ReactNode, createContext, useContext } from 'react' +import type { ApiClient } from './client' + +const ClientContext = createContext(null) + +export function ClientProvider({ client, children }: { client: ApiClient; children: ReactNode }) { + return {children} +} + +/** The client the application was given. A screen rendered outside a provider is a defect, said loudly. */ +export function useClient(): ApiClient { + const c = useContext(ClientContext) + if (c === null) throw new Error('useClient outside a ClientProvider') + return c +} diff --git a/packages/ui/src/api/errors.ts b/packages/ui/src/api/errors.ts new file mode 100644 index 0000000..5228e10 --- /dev/null +++ b/packages/ui/src/api/errors.ts @@ -0,0 +1,17 @@ +/** + * Every failure the interface shows: the service's own code and message, or a + * code of the client's when the service said nothing (`unknown`, `unreachable`, + * `timeout`). `retryAfterSeconds` is the service's `retry-after`, when it sent + * one. + */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly retryAfterSeconds?: number, + ) { + super(message) + this.name = 'ApiError' + } +} diff --git a/packages/ui/src/api/fake.test.ts b/packages/ui/src/api/fake.test.ts new file mode 100644 index 0000000..f725240 --- /dev/null +++ b/packages/ui/src/api/fake.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from './client' +import { fakeClient } from './fake' + +describe('the fake client', () => { + it('rejects a call nobody faked, naming the method', async () => { + const client = fakeClient() + const err = await client.domains().catch((e) => e) + expect(err).toBeInstanceOf(ApiError) + expect([err.status, err.code, err.message]).toEqual([500, 'not_faked', 'domains']) + expect(client.calls).toEqual([{ method: 'domains', args: [] }]) + }) + + it('answers a faked call and records what it was called with', async () => { + const client = fakeClient({ deleteLink: () => Promise.resolve({ ok: true as const }) }) + expect(await client.deleteLink('00000000-0000-4000-8000-0000000000a1')).toEqual({ ok: true }) + expect(client.calls).toEqual([ + { method: 'deleteLink', args: ['00000000-0000-4000-8000-0000000000a1'] }, + ]) + }) + + // Not a request, so nothing to fake: a screen test asserts the real address. + it('keeps the real export address', () => { + const client = fakeClient() + expect( + client.exportUrl({ + from: '2026-09-24T00:00:00.000Z', + to: '2026-09-25T00:00:00.000Z', + country: 'DE', + }), + ).toBe( + '/api/clicks.csv?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&country=DE', + ) + }) +}) diff --git a/packages/ui/src/api/fake.ts b/packages/ui/src/api/fake.ts new file mode 100644 index 0000000..a769290 --- /dev/null +++ b/packages/ui/src/api/fake.ts @@ -0,0 +1,34 @@ +import { type ApiClient, ApiError, createClient } from './client' + +export interface FakeCall { + method: keyof ApiClient + args: unknown[] +} + +/** + * A client for screen tests: every method records what it was called with, + * and answers whatever the test gave it — or, if the test gave it nothing, + * rejects with `not_faked` and the method's name, so a screen that asks for + * something the test did not expect fails with a name rather than hanging on + * a promise that never settles. + * + * `exportUrl` is the one method that is not a request, and it keeps the real + * implementation, so a test can assert the address a screen links to. + */ +export function fakeClient(overrides: Partial = {}): ApiClient & { calls: FakeCall[] } { + const real = createClient({ + fetchImpl: () => Promise.reject(new Error('the fake client never fetches')), + }) + const calls: FakeCall[] = [] + const out: Record = { calls } + for (const name of Object.keys(real) as (keyof ApiClient)[]) { + const given = overrides[name] as ((...a: unknown[]) => unknown) | undefined + out[name] = (...args: unknown[]) => { + calls.push({ method: name, args }) + if (given) return given(...args) + if (name === 'exportUrl') return (real.exportUrl as (...a: unknown[]) => unknown)(...args) + return Promise.reject(new ApiError(500, 'not_faked', String(name))) + } + } + return out as ApiClient & { calls: FakeCall[] } +} diff --git a/packages/ui/src/api/scheduler.test.ts b/packages/ui/src/api/scheduler.test.ts new file mode 100644 index 0000000..732f18e --- /dev/null +++ b/packages/ui/src/api/scheduler.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError } from './errors' +import { createScheduler } from './scheduler' + +/** A promise and the two functions that settle it, so a test decides when a request answers. */ +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const busy = () => new ApiError(429, 'too_many_reports', 'too many reports at once; try again', 1) +const noWait = () => Promise.resolve() + +/** Settles on the next macrotask: what a race against it answers when a promise is stuck. */ +const stillWaiting = () => + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 0)) + +describe('the report scheduler', () => { + it('holds two requests in flight and queues the rest, in order', async () => { + const s = createScheduler({ sleep: noWait }) + const gates = [deferred(), deferred(), deferred(), deferred()] + const started: number[] = [] + const results = gates.map((g, i) => + s.run(() => { + started.push(i) + return g.promise + }), + ) + await Promise.resolve() + expect(started).toEqual([0, 1]) + gates[1]?.resolve('b') + await results[1] + await Promise.resolve() + expect(started).toEqual([0, 1, 2]) + gates[0]?.resolve('a') + await results[0] + await Promise.resolve() + expect(started).toEqual([0, 1, 2, 3]) + gates[2]?.resolve('c') + gates[3]?.resolve('d') + expect(await Promise.all(results)).toEqual(['a', 'b', 'c', 'd']) + }) + + // The overview on a busy install: the server answers "too many reports" + // twice and then the numbers. The operator must see the numbers. + it('waits out a busy server and retries, as many times as it says to wait', async () => { + const sleep = vi.fn(noWait) + const s = createScheduler({ sleep }) + const task = vi + .fn<() => Promise>() + .mockRejectedValueOnce(busy()) + .mockRejectedValueOnce(busy()) + .mockResolvedValueOnce('numbers') + expect(await s.run(task)).toBe('numbers') + expect(task).toHaveBeenCalledTimes(3) + expect(sleep.mock.calls).toEqual([[1000], [1000]]) + }) + + it('waits as long as the server says, not a second whatever it says', async () => { + const sleep = vi.fn(noWait) + const s = createScheduler({ sleep }) + const task = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new ApiError(429, 'too_many_reports', 'busy', 3)) + .mockResolvedValueOnce('ok') + await s.run(task) + expect(sleep.mock.calls).toEqual([[3000]]) + }) + + it('gives up after three retries and says why', async () => { + const s = createScheduler({ sleep: noWait }) + const task = vi.fn<() => Promise>().mockRejectedValue(busy()) + await expect(s.run(task)).rejects.toMatchObject({ status: 429, code: 'too_many_reports' }) + expect(task).toHaveBeenCalledTimes(4) + }) + + it.each([ + ['another refusal', new ApiError(429, 'too_many_exports', 'an export is already running', 1)], + ['a bad request', new ApiError(400, 'invalid_query', 'to: must be after from')], + ['an outage', new ApiError(503, 'reporting_unavailable', 'reporting is not available')], + ])('does not retry %s', async (_label, error) => { + const s = createScheduler({ sleep: noWait }) + const task = vi.fn<() => Promise>().mockRejectedValue(error) + await expect(s.run(task)).rejects.toBe(error) + expect(task).toHaveBeenCalledTimes(1) + }) + + it('gives the slot back when a request fails', async () => { + const s = createScheduler({ limit: 1, sleep: noWait }) + await expect(s.run(() => Promise.reject(new ApiError(400, 'x', 'x')))).rejects.toBeInstanceOf( + ApiError, + ) + expect(await s.run(() => Promise.resolve('next'))).toBe('next') + }) + + // A screen the operator left: its queued requests are dropped without being + // sent, and the ones behind them move up. + it('never sends a request that was abandoned while it waited', async () => { + const s = createScheduler({ limit: 1, sleep: noWait }) + const first = deferred() + const running = s.run(() => first.promise) + const abandoned = new AbortController() + const never = vi.fn(() => Promise.resolve('sent')) + const dropped = s.run(never, abandoned.signal) + const after = s.run(() => Promise.resolve('after')) + abandoned.abort() + first.resolve('first') + await running + await expect(dropped).rejects.toMatchObject({ name: 'AbortError' }) + expect(await after).toBe('after') + expect(never).not.toHaveBeenCalled() + }) + + // Refused at once, even with every slot taken: it is not queued to wait for + // a turn it will never use. + it('refuses a request abandoned before it was asked for, without waiting for a slot', async () => { + const s = createScheduler({ limit: 1, sleep: noWait }) + void s.run(() => new Promise(() => {})) + const gone = new AbortController() + gone.abort() + const task = vi.fn(() => Promise.resolve('x')) + const settled = await Promise.race([ + s.run(task, gone.signal).catch((e: unknown) => e), + stillWaiting(), + ]) + expect(settled).toMatchObject({ name: 'AbortError' }) + expect(task).not.toHaveBeenCalled() + }) + + // A request waiting out a busy server when the operator leaves: it stops + // waiting, is not tried again, and the request behind it gets the slot + // without waiting for the wait to end. + it('stops waiting out a busy server when the request is abandoned, and gives the slot back', async () => { + const sleep = vi.fn((_ms: number) => new Promise(() => {})) + const s = createScheduler({ limit: 1, sleep }) + const left = new AbortController() + const task = vi.fn<() => Promise>().mockRejectedValue(busy()) + const abandoned = s.run(task, left.signal).catch((e: unknown) => e) + const behind = vi.fn(() => Promise.resolve('behind')) + const after = s.run(behind) + await stillWaiting() + expect(sleep.mock.calls).toEqual([[1000]]) + expect(behind).not.toHaveBeenCalled() + left.abort() + expect(await Promise.race([abandoned, stillWaiting()])).toMatchObject({ name: 'AbortError' }) + expect(await Promise.race([after, stillWaiting()])).toBe('behind') + expect(task).toHaveBeenCalledTimes(1) + }) + + // Abandoned after the busy answer came back but before the wait began: an + // abort that already happened fires no event, so it is checked for, not + // listened for. + it('does not start waiting for a request already abandoned when the busy answer arrives', async () => { + const s = createScheduler({ limit: 1, sleep: () => new Promise(() => {}) }) + const left = new AbortController() + const task = vi.fn(() => { + left.abort() + return Promise.reject(busy()) + }) + const abandoned = s.run(task, left.signal).catch((e: unknown) => e) + expect(await Promise.race([abandoned, stillWaiting()])).toMatchObject({ name: 'AbortError' }) + expect(task).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ui/src/api/scheduler.ts b/packages/ui/src/api/scheduler.ts new file mode 100644 index 0000000..662d041 --- /dev/null +++ b/packages/ui/src/api/scheduler.ts @@ -0,0 +1,100 @@ +/** + * Report requests, two at a time. + * + * The admin service runs two report queries at once per process and answers a + * third with 429 and `retry-after: 1`. The overview alone asks for ten. So the + * interface queues its own report requests, in the order they were asked for, + * and holds at most as many in flight as the server will run. A second tab, or + * a script, can still take a slot, which is what the retry is for: a 429 for a + * report is waited out for as long as the server says, up to three times, + * before it is shown to anyone. + * + * A request whose signal is aborted while it waits is dropped without being + * sent — a screen the operator has left should not spend a slot the next screen + * needs. The same holds for one aborted while it waits out a 429: it stops + * waiting at once and gives its slot back. One already sent is the fetch's to + * cancel, not this. + */ +import { ApiError } from './errors' + +export const REPORTS_IN_FLIGHT = 2 +export const REPORT_RETRIES = 3 + +export interface Scheduler { + run(task: () => Promise, signal?: AbortSignal): Promise +} + +const abortError = (): DOMException => new DOMException('the request was abandoned', 'AbortError') + +const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +/** A wait that ends early, with an AbortError, when the signal is aborted. */ +function waitUnless(waiting: Promise, signal?: AbortSignal): Promise { + if (!signal) return waiting + if (signal.aborted) return Promise.reject(abortError()) + return new Promise((resolve, reject) => { + const abandon = (): void => reject(abortError()) + signal.addEventListener('abort', abandon, { once: true }) + waiting.then( + () => { + signal.removeEventListener('abort', abandon) + resolve() + }, + (err: unknown) => { + signal.removeEventListener('abort', abandon) + reject(err) + }, + ) + }) +} + +export function createScheduler( + o: { limit?: number; retries?: number; sleep?: (ms: number) => Promise } = {}, +): Scheduler { + const limit = o.limit ?? REPORTS_IN_FLIGHT + const retries = o.retries ?? REPORT_RETRIES + const sleep = o.sleep ?? wait + let running = 0 + const queue: (() => void)[] = [] + + const next = (): void => { + while (running < limit && queue.length > 0) queue.shift()?.() + } + + async function attempt(task: () => Promise, signal?: AbortSignal): Promise { + for (let tried = 0; ; tried++) { + try { + return await task() + } catch (err) { + const busy = + err instanceof ApiError && err.status === 429 && err.code === 'too_many_reports' + if (!busy || tried >= retries) throw err + await waitUnless(sleep((err.retryAfterSeconds ?? 1) * 1000), signal) + } + } + } + + return { + run(task: () => Promise, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(abortError()) + return new Promise((resolve, reject) => { + const start = (): void => { + // Called from `next`, whose loop moves on to the one behind. + if (signal?.aborted) { + reject(abortError()) + return + } + running += 1 + attempt(task, signal) + .then(resolve, reject) + .finally(() => { + running -= 1 + next() + }) + } + queue.push(start) + next() + }) + }, + } +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts new file mode 100644 index 0000000..f612f42 --- /dev/null +++ b/packages/ui/src/api/types.ts @@ -0,0 +1,267 @@ +/** + * The admin API's requests and responses, as the interface uses them. Written + * from the routes; nothing else in this package declares an API shape. + * + * Instants are ISO strings with milliseconds and `Z`, exactly as the service + * sends them. Nothing here is a `Date`: a value is parsed where it is shown, + * by `app/format.ts`, and only there. + */ + +export type Instant = string + +export interface Me { + email: string + totpEnabled: boolean + recoveryCodesLeft: number + failedLogins?: number + lockedUntil?: Instant | null + credential: 'session' | 'key' +} + +export interface SignIn { + email: string + password: string + code?: string + recoveryCode?: string +} + +export interface Session { + id: string + createdAt: Instant + lastSeenAt: Instant + expiresAt: Instant + userAgent: string + /** The whole address this session was opened from — the operator's own. */ + ip: string + current: boolean +} + +export interface ApiKey { + id: string + name: string + createdAt: Instant + lastUsedAt: Instant | null + expiresAt: Instant | null + revokedAt: Instant | null +} + +/** The one response that carries a key's secret, once. */ +export interface NewApiKey { + id: string + name: string + key: string + expiresAt: Instant | null +} + +export type DomainStatus = 'verified' | 'missing_token' | 'error' + +export interface Domain { + id: string + host: string + verified: boolean + rootUrl: string | null + notFoundUrl: string | null + verificationRecord: { name: string; type: 'TXT'; value: string } + lastCheck: { status: DomainStatus; detail: string | null; checkedAt: Instant } | null +} + +export interface DomainCheck { + status: DomainStatus + detail: string | null +} + +export type TrafficAction = 'nothing' | 'flag' | 'block' | 'safe' +export type NonHumanClass = 'bot' | 'abuser' | 'anonymous' | 'datacenter' + +export type CountryRule = + | { mode: 'all' } + | { mode: 'allow'; list: string[] } + | { mode: 'block'; list: string[] } + +export interface Target { + id: string + url: string + weight: number +} + +export interface Link { + id: string + domainId: string + host: string + slug: string + url: string + name: string | null + enabled: boolean + targets: Target[] + backupUrl: string | null + deviceUrls: { ios?: string; android?: string; desktop?: string } + returningUrl: string | null + countries: CountryRule + clickCap: number | null + capUsed: number | null + expiresAt: Instant | null + passthrough: boolean + trafficActions: Partial> + hasPassword: boolean + createdAt: Instant +} + +/** What a create sends. `host` names the domain; everything else as `Link`. */ +export interface LinkInput { + host: string + slug?: string + name?: string | null + enabled?: boolean + targets: { url: string; weight?: number }[] + backupUrl?: string | null + deviceUrls?: { ios?: string; android?: string; desktop?: string } + returningUrl?: string | null + countries?: CountryRule + clickCap?: number | null + expiresAt?: Instant | null + passthrough?: boolean + trafficActions?: Partial> + password?: string | null +} + +/** What an edit sends: only what changed. `password` absent leaves it alone. */ +export type LinkPatch = Partial> + +export interface Page { + items: T[] + nextCursor: string | null +} + +export interface TrafficSettings { + actions: Record + safeUrl: string | null + abuserThreshold: number +} + +export interface RetentionSettings { + rawRetentionDays: number | null + ipRetentionDays: number | null +} + +export interface Settings { + traffic: TrafficSettings + /** Null when the stored row cannot be read — which is not "for ever". */ + retention: RetentionSettings | null + note: string | null + problem: string | null +} + +export interface SettingsInput { + traffic: TrafficSettings + retention: RetentionSettings +} + +/** The window a report counted, which may be wider than the one asked for. */ +export interface ReportWindow { + from: Instant + to: Instant +} + +export interface WindowQuery { + from: Instant + to: Instant + link?: string +} + +export interface Summary { + window: ReportWindow + link: string | null + clicks: number + visitors: number + byClass: Record + byAction: Record + byOutcome: Record + newestHour: Instant | null +} + +export interface Timeseries { + window: ReportWindow + link: string | null + bucket: 'hour' | 'day' + buckets: { at: Instant; clicks: number; visitors: number }[] + newestHour: Instant | null +} + +export interface BreakdownRow { + value: string + clicks: number + visitors: number + /** Only on a breakdown by link: the link the value names, or null when there is none. */ + link?: { slug: string; host: string; name: string | null } | null +} + +export interface Breakdown { + window: ReportWindow + link: string | null + dimension: string + truncated: boolean + rows: BreakdownRow[] +} + +export interface ClickFilters extends WindowQuery { + class?: string + outcome?: string + country?: string +} + +export interface Click { + clickId: string + at: Instant + host: string + path: string + domainId: string + linkId: string + outcome: string + step: string + status: number + destination: string | null + targetId: string | null + visitorId: string + returning: boolean + country: string | null + region: string | null + city: string | null + geoSource: string | null + device: string + os: string | null + browser: string | null + asn: number | null + class: string | null + signals: string[] + action: string | null + referrer: string | null + userAgent: string | null + /** Always a network, never an address; null when blanked or unreadable. */ + network: string | null + capUnchecked: boolean +} + +export interface ClickPage { + window: ReportWindow + link: string | null + clicks: Click[] + nextCursor: string | null +} + +export interface ClickCount { + window: ReportWindow + link: string | null + count: number + cap: number + truncated: boolean +} + +export type IpSourceStatus = { version: string; fetchedAt: Instant } | null + +export interface Status { + newestHour: Instant | null + reporting: 'ok' | 'unavailable' + ipData: Record<'country' | 'asn' | 'datacenter' | 'tor', IpSourceStatus> | null + ipDataProblem: string | null + alerts: number +} diff --git a/packages/ui/src/api/vocabulary.test.ts b/packages/ui/src/api/vocabulary.test.ts new file mode 100644 index 0000000..33728c4 --- /dev/null +++ b/packages/ui/src/api/vocabulary.test.ts @@ -0,0 +1,62 @@ +import { + TRAFFIC_ACTIONS as CORE_ACTIONS, + TRAFFIC_CLASSES as CORE_CLASSES, + DEVICES as CORE_DEVICES, + REPORT_DIMENSIONS as CORE_DIMENSIONS, + MAX_ABUSER_THRESHOLD as CORE_MAX_ABUSER_THRESHOLD, + MAX_RETENTION_DAYS as CORE_MAX_RETENTION_DAYS, + MIN_ADMIN_PASSWORD_LENGTH as CORE_MIN_ADMIN_PASSWORD_LENGTH, + MIN_LINK_PASSWORD_LENGTH as CORE_MIN_LINK_PASSWORD_LENGTH, + NON_HUMAN_CLASSES as CORE_NON_HUMAN, + OUTCOMES as CORE_OUTCOMES, +} from '@clickmonk/core' +import { SOURCE_IDS } from '@clickmonk/ipdata' +import { describe, expect, it } from 'vitest' +import { + DEVICES, + IP_SOURCES, + MAX_ABUSER_THRESHOLD, + MAX_RETENTION_DAYS, + MIN_ADMIN_PASSWORD_LENGTH, + MIN_LINK_PASSWORD_LENGTH, + NON_HUMAN_CLASSES, + OUTCOMES, + REPORT_DIMENSIONS, + TRAFFIC_ACTIONS, + TRAFFIC_CLASSES, +} from './vocabulary' + +// Each list against the service's, in order: an order that differs is a select +// box whose options move, and a value missing is one the interface cannot ask for. +describe('the lists the interface offers', () => { + it.each([ + ['traffic classes', TRAFFIC_CLASSES, CORE_CLASSES], + ['non-human classes', NON_HUMAN_CLASSES, CORE_NON_HUMAN], + ['actions', TRAFFIC_ACTIONS, CORE_ACTIONS], + ['outcomes', OUTCOMES, CORE_OUTCOMES], + ['dimensions', REPORT_DIMENSIONS, CORE_DIMENSIONS], + ['devices', DEVICES, CORE_DEVICES], + ['ip data sources', IP_SOURCES, SOURCE_IDS], + ])('restates the service’s %s exactly', (_label, ours, theirs) => { + expect([...ours]).toEqual([...theirs]) + }) +}) + +// Each bound below is checked locally, before the service is asked, and is +// owned by `core`. A test that only compared the lists above would stay +// green the day one of these drifts and a form starts refusing a value the +// service would accept. +describe('the numeric bounds the interface checks locally', () => { + it.each([ + [ + 'an admin password’s minimum length', + MIN_ADMIN_PASSWORD_LENGTH, + CORE_MIN_ADMIN_PASSWORD_LENGTH, + ], + ['a link password’s minimum length', MIN_LINK_PASSWORD_LENGTH, CORE_MIN_LINK_PASSWORD_LENGTH], + ['the abuser threshold’s ceiling', MAX_ABUSER_THRESHOLD, CORE_MAX_ABUSER_THRESHOLD], + ['a retention period’s ceiling', MAX_RETENTION_DAYS, CORE_MAX_RETENTION_DAYS], + ])('restates the service’s %s exactly', (_label, ours, theirs) => { + expect(ours).toBe(theirs) + }) +}) diff --git a/packages/ui/src/api/vocabulary.ts b/packages/ui/src/api/vocabulary.ts new file mode 100644 index 0000000..bec8e2a --- /dev/null +++ b/packages/ui/src/api/vocabulary.ts @@ -0,0 +1,104 @@ +/** + * The closed lists the interface offers, restated from the service's own. + * + * Restated rather than imported because the interface imports nothing from the + * shared package at runtime: its root pulls in Node's crypto module, and a + * bundle that evaluates a server module at load is a blank page. The test + * beside this file imports the service's lists — in Node, where that is fine — + * and fails when the two differ, so a value added there is a failing test here + * rather than a filter that cannot select it. + */ + +export const NON_HUMAN_CLASSES = ['bot', 'abuser', 'anonymous', 'datacenter'] as const +export const TRAFFIC_CLASSES = [...NON_HUMAN_CLASSES, 'human', 'unknown'] as const +export const TRAFFIC_ACTIONS = ['nothing', 'flag', 'block', 'safe'] as const +export const OUTCOMES = [ + 'target', + 'device', + 'returning', + 'root', + 'not_found', + 'unknown_domain', + 'expired', + 'capped', + 'country_blocked', + 'blocked', + 'safe', + 'password', +] as const +export const REPORT_DIMENSIONS = [ + 'country', + 'device', + 'os', + 'browser', + 'referrer', + 'target', + 'class', + 'action', + 'outcome', + 'link', +] as const +export const DEVICES = ['ios', 'android', 'desktop'] as const +export const IP_SOURCES = ['country', 'asn', 'datacenter', 'tor'] as const + +/** + * Numeric bounds a form checks locally before asking the service, restated + * from `core` for the same reason the lists above are: the test beside this + * file fails the moment one drifts from the number `core` actually enforces, + * rather than a form silently refusing a value the service would accept. + */ +export const MIN_ADMIN_PASSWORD_LENGTH = 12 +export const MIN_LINK_PASSWORD_LENGTH = 6 +export const MAX_ABUSER_THRESHOLD = 100_000 +export const MAX_RETENTION_DAYS = 3650 + +export const CLASS_LABELS: Record<(typeof TRAFFIC_CLASSES)[number], string> = { + bot: 'Bot', + abuser: 'Abuser', + anonymous: 'Anonymous', + datacenter: 'Datacenter', + human: 'Human', + unknown: 'Unknown', +} + +export const ACTION_LABELS: Record<(typeof TRAFFIC_ACTIONS)[number], string> = { + nothing: 'Count it', + flag: 'Flag it', + block: 'Block it', + safe: 'Send to the safe URL', +} + +export const OUTCOME_LABELS: Record<(typeof OUTCOMES)[number], string> = { + target: 'Sent to a target', + device: 'Sent to a device URL', + returning: 'Sent to the returning-visitor URL', + root: 'Domain root', + not_found: 'Unknown slug', + unknown_domain: 'Unknown domain', + expired: 'Link expired', + capped: 'Click cap reached', + country_blocked: 'Country not allowed', + blocked: 'Blocked', + safe: 'Sent to the safe URL', + password: 'Asked for the password', +} + +export const DIMENSION_LABELS: Record<(typeof REPORT_DIMENSIONS)[number], string> = { + country: 'Countries', + device: 'Devices', + os: 'Operating systems', + browser: 'Browsers', + referrer: 'Referrers', + target: 'Targets', + class: 'Traffic classes', + action: 'Actions', + outcome: 'Outcomes', + link: 'Links', +} + +export const IP_SOURCE_LABELS: Record<(typeof IP_SOURCES)[number], string> = { + country: 'Countries', + asn: 'Networks', + datacenter: 'Hosting networks', + tor: 'Tor exits', +} diff --git a/packages/ui/src/app/Confirm.test.tsx b/packages/ui/src/app/Confirm.test.tsx new file mode 100644 index 0000000..7951c69 --- /dev/null +++ b/packages/ui/src/app/Confirm.test.tsx @@ -0,0 +1,121 @@ +import { Button } from '@/components/ui/button' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { Confirm } from './Confirm' + +describe('confirming a destructive action', () => { + it('names the consequence and the action, and does nothing until confirmed', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + render( + Delete} + />, + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Delete' })) + expect(screen.getByRole('alertdialog', { name: 'Delete go.example.test?' })).toHaveTextContent( + 'Its 12 links, their targets and their counters', + ) + expect(onConfirm).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onConfirm).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Delete' })) + await user.click(screen.getByRole('button', { name: 'Delete domain' })) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('keeps the dialog open and says why when the action fails', async () => { + const { ApiError } = await import('@/api/errors') + render( + + Promise.reject(new ApiError(404, 'not_found', 'no such key, or it was already revoked')) + } + trigger={} + />, + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Revoke' })) + await user.click(screen.getByRole('button', { name: 'Revoke key' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'no such key, or it was already revoked', + ) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + }) + + it('does not show the previous failure when reopened', async () => { + const { ApiError } = await import('@/api/errors') + render( + + Promise.reject(new ApiError(404, 'not_found', 'no such key, or it was already revoked')) + } + trigger={} + />, + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Revoke' })) + await user.click(screen.getByRole('button', { name: 'Revoke key' })) + expect(await screen.findByRole('alert')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await user.click(screen.getByRole('button', { name: 'Revoke' })) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('reopens after the browser closes it (Escape, or a method="dialog" form)', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + render( + Revoke} + />, + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Revoke' })) + act(() => (screen.getByRole('alertdialog') as HTMLDialogElement).close()) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Revoke' })) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + }) + + it('disables the confirm button while busy, so a second click cannot run it twice', async () => { + let settle: () => void = () => {} + const onConfirm = vi.fn( + () => + new Promise((resolve) => { + settle = resolve + }), + ) + render( + Revoke} + />, + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Revoke' })) + await user.click(screen.getByRole('button', { name: 'Revoke key' })) + expect(screen.getByRole('button', { name: 'Revoke key' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'Revoke key' })) + expect(onConfirm).toHaveBeenCalledTimes(1) + await act(async () => settle()) + }) +}) diff --git a/packages/ui/src/app/Confirm.tsx b/packages/ui/src/app/Confirm.tsx new file mode 100644 index 0000000..44703d5 --- /dev/null +++ b/packages/ui/src/app/Confirm.tsx @@ -0,0 +1,75 @@ +import { ApiError } from '@/api/errors' +import { Button } from '@/components/ui/button' +import { Modal } from '@/components/ui/modal' +import { type ReactElement, type ReactNode, cloneElement, useState } from 'react' +import { ErrorNote } from './ErrorNote' + +/** + * A destructive action, confirmed. The body says what happens, in the terms the + * service defines it; the confirm button is named by the action, never "OK". + * A failure keeps the dialog open and says why, rather than closing it on an + * action that did not happen. + */ +export function Confirm(props: { + title: string + body: ReactNode + action: string + destructive?: boolean + onConfirm: () => Promise + trigger: ReactElement<{ onClick?: () => void }> +}) { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + // An error that is not an `ApiError` is a defect, not a refusal to show. It + // is rethrown to React the way `useLoad` does: an async event handler's own + // returned promise is never awaited by anything, so throwing straight from + // `catch` here would only be an unhandled rejection, never seen. + const [thrown, setThrown] = useState(null) + if (thrown !== null) throw thrown + const confirm = async () => { + setBusy(true) + setError(null) + try { + await props.onConfirm() + setOpen(false) + } catch (err) { + if (err instanceof ApiError) setError(err) + else setThrown(err) + } finally { + setBusy(false) + } + } + return ( + <> + {cloneElement(props.trigger, { + onClick: () => { + setError(null) + setOpen(true) + }, + })} + setOpen(false)} + title={props.title} + description={props.body} + alert + > + {error && } +
+ + +
+
+ + ) +} diff --git a/packages/ui/src/app/CopyButton.test.tsx b/packages/ui/src/app/CopyButton.test.tsx new file mode 100644 index 0000000..0554892 --- /dev/null +++ b/packages/ui/src/app/CopyButton.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CopyButton } from './CopyButton' + +describe('copying', () => { + it('copies the value and says so', async () => { + const user = userEvent.setup() + render( + , + ) + await user.click(screen.getByRole('button', { name: 'Copy the TXT value' })) + expect(await navigator.clipboard.readText()).toBe( + 'clickmonk-verify=0123456789abcdef0123456789abcdef', + ) + expect(screen.getByRole('status')).toHaveTextContent('Copied') + }) + + // navigator.clipboard can refuse (plain http on a LAN address) or be + // absent. A click that fails is not silence. + it('says so when the clipboard refuses, and never claims it copied', async () => { + const user = userEvent.setup() + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')) + render() + await user.click(screen.getByRole('button', { name: 'Copy the value' })) + expect(await screen.findByRole('status')).toHaveTextContent( + 'Could not copy. Select the value and copy it.', + ) + expect(screen.getByRole('status')).not.toHaveTextContent('Copied') + }) + + afterEach(() => vi.restoreAllMocks()) +}) diff --git a/packages/ui/src/app/CopyButton.tsx b/packages/ui/src/app/CopyButton.tsx new file mode 100644 index 0000000..d9adaa5 --- /dev/null +++ b/packages/ui/src/app/CopyButton.tsx @@ -0,0 +1,37 @@ +import { Button } from '@/components/ui/button' +import { Copy } from 'lucide-react' +import { useState } from 'react' + +/** + * Copies a value — a TXT record, a new key — and says it did, to a screen + * reader as well. `navigator.clipboard` can refuse or be absent (plain http + * on a LAN address, a browser without permission), and that is said too, + * rather than left as a click that did nothing and an unhandled rejection. + */ +export function CopyButton({ value, label }: { value: string; label: string }) { + const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle') + return ( + + + + {status === 'copied' && 'Copied'} + {status === 'failed' && 'Could not copy. Select the value and copy it.'} + + + ) +} diff --git a/packages/ui/src/app/ErrorNote.test.tsx b/packages/ui/src/app/ErrorNote.test.tsx new file mode 100644 index 0000000..e0bb2b3 --- /dev/null +++ b/packages/ui/src/app/ErrorNote.test.tsx @@ -0,0 +1,48 @@ +import { ApiError } from '@/api/errors' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ErrorNote } from './ErrorNote' + +describe('an error, said', () => { + it.each([ + [ + new ApiError(400, 'invalid_link', 'targets: weights must sum to 100, got 90'), + 'targets: weights must sum to 100, got 90', + ], + [ + new ApiError(0, 'unreachable', 'ClickMonk did not answer'), + 'ClickMonk did not answer. Check that it is running, then try again.', + ], + [ + new ApiError(0, 'timeout', 'ClickMonk took too long to answer'), + 'ClickMonk took too long to answer. Try again.', + ], + [ + new ApiError(502, 'unknown', 'the service answered 502'), + 'Something between this browser and ClickMonk answered 502.', + ], + [ + new ApiError(429, 'too_many_reports', 'too many reports at once; try again', 1), + 'ClickMonk is busy with other reports. Try again in a moment.', + ], + [ + new ApiError(503, 'reporting_unavailable', 'reporting is not available on this install'), + 'Reporting is not available right now: ClickMonk cannot reach its click store.', + ], + [ + new ApiError(429, 'locked', 'too many failed attempts', 300), + 'too many failed attempts. Try again in 5 minutes.', + ], + ])('says %s as the operator should read it', (error, text) => { + render() + expect(screen.getByRole('alert')).toHaveTextContent(text) + }) + + // `toHaveTextContent` above is a substring match, which "no such key.." + // would also satisfy — jest-dom has no exact option for it — so the + // double-full-stop guard needs a direct comparison of the whole text. + it('collapses a message that already ends in a full stop, rather than doubling it', () => { + render() + expect(screen.getByRole('alert').textContent).toBe('no such key.') + }) +}) diff --git a/packages/ui/src/app/ErrorNote.tsx b/packages/ui/src/app/ErrorNote.tsx new file mode 100644 index 0000000..26a44b8 --- /dev/null +++ b/packages/ui/src/app/ErrorNote.tsx @@ -0,0 +1,36 @@ +import type { ApiError } from '@/api/errors' + +/** + * An error as the operator should read it. The service's own message where it + * wrote one for a person; a sentence of this package's where the failure was + * not the service's (no answer, a proxy's page) or where the service's message + * was written for a script (a busy gate). A wait is said in words. + */ +export function explain(e: ApiError): string { + const wait = + e.retryAfterSeconds && e.retryAfterSeconds >= 60 + ? ` Try again in ${Math.ceil(e.retryAfterSeconds / 60)} minutes.` + : '' + switch (e.code) { + case 'unreachable': + return 'ClickMonk did not answer. Check that it is running, then try again.' + case 'timeout': + return 'ClickMonk took too long to answer. Try again.' + case 'unknown': + return `Something between this browser and ClickMonk answered ${e.status}.` + case 'too_many_reports': + return 'ClickMonk is busy with other reports. Try again in a moment.' + case 'reporting_unavailable': + return 'Reporting is not available right now: ClickMonk cannot reach its click store.' + default: + return `${e.message}.${wait}`.replace(/\.\./g, '.') + } +} + +export function ErrorNote({ error }: { error: ApiError }) { + return ( +

+ {explain(error)} +

+ ) +} diff --git a/packages/ui/src/app/Freshness.test.tsx b/packages/ui/src/app/Freshness.test.tsx new file mode 100644 index 0000000..b2782a7 --- /dev/null +++ b/packages/ui/src/app/Freshness.test.tsx @@ -0,0 +1,172 @@ +import { ClientProvider } from '@/api/context' +import { ApiError } from '@/api/errors' +import { fakeClient } from '@/api/fake' +import type { Status } from '@/api/types' +import { NowProvider } from '@/app/clock' +import { RefreshProvider, useRefresh } from '@/app/refresh' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import { describe, expect, it } from 'vitest' +import { Freshness } from './Freshness' + +const NOW = () => Date.parse('2026-10-07T03:10:00.000Z') +const base: Status = { + newestHour: null, + reporting: 'ok', + ipData: null, + ipDataProblem: null, + alerts: 0, +} + +function show(status: Status) { + render( + + + Promise.resolve(status) })}> + + + + , + ) +} + +function RefreshButton() { + const { refresh } = useRefresh() + return ( + + ) +} + +function showWithRefresh(status: () => Promise) { + render( + + + + + + + + + + , + ) + return { user: userEvent.setup() } +} + +describe('how fresh the numbers are', () => { + it('names the newest hour the reports hold, in local time', async () => { + show({ ...base, newestHour: '2026-10-07T02:00:00.000Z' }) + expect( + await screen.findByText('Reports include clicks up to 7 Oct 2026, 12:30–13:30.'), + ).toBeInTheDocument() + }) + + it('says when nothing has been recorded yet', async () => { + show(base) + expect(await screen.findByText('No clicks have reached the reports yet.')).toBeInTheDocument() + }) + + it('says when reporting is down', async () => { + show({ ...base, reporting: 'unavailable' }) + expect( + await screen.findByText('Reporting is unavailable: ClickMonk cannot reach its click store.'), + ).toBeInTheDocument() + }) + + it('says how old the oldest IP list is', async () => { + show({ + ...base, + ipData: { + country: { version: '2026-10', fetchedAt: '2026-10-05T03:10:00.000Z' }, + asn: { version: '2026-10', fetchedAt: '2026-10-06T03:10:00.000Z' }, + datacenter: null, + tor: { version: 'x', fetchedAt: '2026-10-07T02:10:00.000Z' }, + }, + }) + expect( + await screen.findByText('IP lists updated 2 days ago; hosting networks never fetched.'), + ).toBeInTheDocument() + }) + + it('says there is no IP data, and what that means', async () => { + show(base) + expect( + await screen.findByText( + 'No IP data: countries and networks are unknown, and clicks are classed unknown rather than human.', + ), + ).toBeInTheDocument() + }) + + it('says the IP data could not be read', async () => { + show({ ...base, ipDataProblem: 'the IP data manifest could not be read' }) + expect(await screen.findByText('The IP data could not be read.')).toBeInTheDocument() + }) + + it('says nothing when no domain needs attention', async () => { + show({ ...base, alerts: 0 }) + // Wait for the status to have rendered before asserting an absence, so + // this cannot pass merely because nothing has loaded yet. + await screen.findByText('No clicks have reached the reports yet.') + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('names one domain in the singular', async () => { + show({ ...base, alerts: 1 }) + expect( + await screen.findByRole('link', { name: '1 domain needs attention' }), + ).toBeInTheDocument() + }) + + it('links to the domains that need attention', async () => { + show({ ...base, alerts: 2 }) + const link = await screen.findByRole('link', { name: '2 domains need attention' }) + expect(link).toHaveAttribute('href', '/domains') + }) + + it('counts past five hundred as five hundred and more', async () => { + show({ ...base, alerts: 501 }) + expect( + await screen.findByRole('link', { name: '500+ domains need attention' }), + ).toBeInTheDocument() + }) + + it('writes exactly five hundred as 500, not 500+', async () => { + show({ ...base, alerts: 500 }) + expect( + await screen.findByRole('link', { name: '500 domains need attention' }), + ).toBeInTheDocument() + }) + + // A refresh that fails must not leave the previous success's numbers on + // screen looking current: `useLoad` keeps them beside `state: 'error'`, so + // this line has to check the state, not just whether `data` is set. + it('goes silent, rather than keeping stale numbers on screen, once a reload fails', async () => { + let calls = 0 + const { user } = showWithRefresh(() => { + calls += 1 + if (calls === 1) + return Promise.resolve({ + ...base, + newestHour: '2026-10-07T02:00:00.000Z', + alerts: 2, + }) + return Promise.reject(new ApiError(500, 'server_error', 'the service is down')) + }) + expect( + await screen.findByText('Reports include clicks up to 7 Oct 2026, 12:30–13:30.'), + ).toBeInTheDocument() + expect( + await screen.findByRole('link', { name: '2 domains need attention' }), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Refresh' })) + await waitFor(() => + expect( + screen.queryByText('Reports include clicks up to 7 Oct 2026, 12:30–13:30.'), + ).not.toBeInTheDocument(), + ) + expect(screen.queryByRole('link', { name: '2 domains need attention' })).not.toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/app/Freshness.tsx b/packages/ui/src/app/Freshness.tsx new file mode 100644 index 0000000..12700df --- /dev/null +++ b/packages/ui/src/app/Freshness.tsx @@ -0,0 +1,66 @@ +import { useClient } from '@/api/context' +import type { Status } from '@/api/types' +import { IP_SOURCES, IP_SOURCE_LABELS } from '@/api/vocabulary' +import { browserZone, useNowMs } from '@/app/clock' +import { Link } from 'react-router' +import { formatAge, formatInstant, formatNumber } from './format' +import { useRefresh } from './refresh' +import { useLoad } from './useLoad' + +const HOUR = 3_600_000 + +/** One line per fact, each a sentence; the header shows them small. */ +export function describeStatus(s: Status, nowMs: number, timeZone: string): string[] { + const lines: string[] = [] + if (s.reporting === 'unavailable') + lines.push('Reporting is unavailable: ClickMonk cannot reach its click store.') + else if (s.newestHour === null) lines.push('No clicks have reached the reports yet.') + else { + const start = formatInstant(s.newestHour, timeZone) + const end = formatInstant( + new Date(Date.parse(s.newestHour) + HOUR).toISOString(), + timeZone, + ).split(', ')[1] + lines.push(`Reports include clicks up to ${start}–${end}.`) + } + if (s.ipDataProblem !== null) lines.push('The IP data could not be read.') + else if (s.ipData === null) + lines.push( + 'No IP data: countries and networks are unknown, and clicks are classed unknown rather than human.', + ) + else { + const fetched = IP_SOURCES.map((id) => s.ipData?.[id]?.fetchedAt).filter( + (t): t is string => !!t, + ) + const missing = IP_SOURCES.filter((id) => !s.ipData?.[id]).map((id) => + IP_SOURCE_LABELS[id].toLowerCase(), + ) + const oldest = fetched.length ? Math.min(...fetched.map((t) => Date.parse(t))) : null + const age = + oldest === null ? 'IP lists never fetched' : `IP lists updated ${formatAge(oldest, nowMs)}` + lines.push(`${age}${missing.length ? `; ${missing.join(', ')} never fetched` : ''}.`) + } + return lines +} + +export function Freshness() { + const client = useClient() + const { round } = useRefresh() + const now = useNowMs() + const r = useLoad((signal) => client.status(signal), [round]) + if (r.state === 'error' || !r.data) return null + const s = r.data + const alerts = s.alerts > 500 ? '500+' : formatNumber(s.alerts) + return ( +
+ {describeStatus(s, now, browserZone()).map((line) => ( +

{line}

+ ))} + {s.alerts > 0 && ( + + {`${alerts} domain${s.alerts === 1 ? ' needs' : 's need'} attention`} + + )} +
+ ) +} diff --git a/packages/ui/src/app/PageHeader.tsx b/packages/ui/src/app/PageHeader.tsx new file mode 100644 index 0000000..64e1d8a --- /dev/null +++ b/packages/ui/src/app/PageHeader.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react' + +/** A screen's title — its only `

` — with an optional line under it and actions beside it. */ +export function PageHeader({ + title, + description, + actions, +}: { title: string; description?: ReactNode; actions?: ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ) +} diff --git a/packages/ui/src/app/Router.tsx b/packages/ui/src/app/Router.tsx new file mode 100644 index 0000000..cac8220 --- /dev/null +++ b/packages/ui/src/app/Router.tsx @@ -0,0 +1,35 @@ +import { NotFound } from '@/screens/NotFound' +import { Account } from '@/screens/account/Account' +import { Clicks } from '@/screens/clicks/Clicks' +import { Domains } from '@/screens/domains/Domains' +import { LinkForm } from '@/screens/links/LinkForm' +import { LinkReport } from '@/screens/links/LinkReport' +import { Links } from '@/screens/links/Links' +import { Overview } from '@/screens/overview/Overview' +import { Settings } from '@/screens/settings/Settings' +import { Navigate, Route, Routes } from 'react-router' +import { useMe } from './me' + +/** The account screen, reading who is signed in from the context `App` set up. */ +function AccountRoute() { + const { me, refreshMe } = useMe() + return +} + +export function AppRoutes() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) +} diff --git a/packages/ui/src/app/Shell.test.tsx b/packages/ui/src/app/Shell.test.tsx new file mode 100644 index 0000000..36c33e9 --- /dev/null +++ b/packages/ui/src/app/Shell.test.tsx @@ -0,0 +1,90 @@ +import { ClientProvider } from '@/api/context' +import { fakeClient } from '@/api/fake' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import { describe, expect, it } from 'vitest' +import { Shell } from './Shell' +import { RefreshProvider } from './refresh' + +const STATUS = { + newestHour: null, + reporting: 'ok', + ipData: null, + ipDataProblem: null, + alerts: 0, +} as const + +function show(at = '/links') { + const client = fakeClient({ status: () => Promise.resolve(STATUS) }) + render( + + + + {}}> +

screen

+
+
+
+
, + ) + return client +} + +describe('the shell', () => { + it('links every screen and marks the one that is open', () => { + show('/links') + const nav = screen.getByRole('navigation', { name: 'Main' }) + const names = within(nav) + .getAllByRole('link') + .map((a) => [a.textContent, a.getAttribute('href')]) + expect(names).toEqual([ + ['Overview', '/overview'], + ['Links', '/links'], + ['Clicks', '/clicks'], + ['Domains', '/domains'], + ['Settings', '/settings'], + ['Account', '/account'], + ]) + expect(within(nav).getByRole('link', { name: 'Links' })).toHaveAttribute('aria-current', 'page') + }) + + // The active item's fill against the nav's own sunken background is the + // only visual cue that it is current — aria-current reaches assistive + // technology alone — so it has to be the strong accent fill, not the pale + // accent-surface tint that barely differs from the nav behind it. + it('marks the open screen with the strong accent fill, not a pale tint', () => { + show('/links') + const active = screen.getByRole('link', { name: 'Links' }) + expect(active).toHaveClass('bg-primary', 'text-primary-foreground') + expect(active).not.toHaveClass('bg-accent', 'text-accent-foreground') + }) + + it('names the zone times are shown in', () => { + show() + expect(screen.getByText('Times in Australia/Adelaide')).toBeInTheDocument() + }) + + it('credits the IP data as its licence requires', () => { + show() + const credit = screen.getByRole('contentinfo') + expect(within(credit).getByRole('link', { name: 'DB-IP' })).toHaveAttribute( + 'href', + 'https://db-ip.com', + ) + expect(within(credit).getByRole('link', { name: 'CC BY 4.0' })).toHaveAttribute( + 'href', + 'https://creativecommons.org/licenses/by/4.0/', + ) + expect(credit).toHaveTextContent( + 'IP geolocation by DB-IP, licensed under CC BY 4.0. ClickMonk converts it to its own lookup format.', + ) + }) + + it('reloads what is on screen when Refresh is pressed', async () => { + const client = show() + await screen.findByText('No clicks have reached the reports yet.') + await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' })) + await waitFor(() => expect(client.calls.filter((c) => c.method === 'status')).toHaveLength(2)) + }) +}) diff --git a/packages/ui/src/app/Shell.tsx b/packages/ui/src/app/Shell.tsx new file mode 100644 index 0000000..7c9ab99 --- /dev/null +++ b/packages/ui/src/app/Shell.tsx @@ -0,0 +1,86 @@ +import { browserZone } from '@/app/clock' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import type { ReactNode } from 'react' +import { NavLink } from 'react-router' +import { Freshness } from './Freshness' +import { ThemeToggle } from './ThemeToggle' +import { useRefresh } from './refresh' + +const SCREENS = [ + ['/overview', 'Overview'], + ['/links', 'Links'], + ['/clicks', 'Clicks'], + ['/domains', 'Domains'], + ['/settings', 'Settings'], + ['/account', 'Account'], +] as const + +/** + * The frame around every signed-in screen: where to go, how fresh the numbers + * are, which zone times are in, the theme, signing out — and, in the footer, + * the credit the IP data's licence requires wherever that data is shown. + */ +export function Shell({ + email, + onSignOut, + children, +}: { email: string; onSignOut: () => void; children: ReactNode }) { + const { refresh } = useRefresh() + return ( +
+ +
+
+ +
+ {`Times in ${browserZone()}`} + + + +
+
+
{children}
+
+
+ IP geolocation by{' '} + + DB-IP + + , licensed under{' '} + + CC BY 4.0 + + . ClickMonk converts it to its own lookup format. +
+
+ ) +} diff --git a/packages/ui/src/app/ThemeToggle.test.tsx b/packages/ui/src/app/ThemeToggle.test.tsx new file mode 100644 index 0000000..042bd1c --- /dev/null +++ b/packages/ui/src/app/ThemeToggle.test.tsx @@ -0,0 +1,64 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ThemeToggle } from './ThemeToggle' + +describe('the theme', () => { + it('offers the other theme, stores the choice and applies it', async () => { + render() + await userEvent.setup().click(screen.getByRole('button', { name: 'Switch to dark theme' })) + expect(localStorage.getItem('cm-theme')).toBe('dark') + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument() + }) + + it('starts from what is stored', () => { + localStorage.setItem('cm-theme', 'dark') + render() + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument() + }) + + describe('with no stored choice', () => { + afterEach(() => vi.unstubAllGlobals()) + + /** A system scheme that can change while the page is open, as a real one can. */ + function systemScheme(initiallyDark: boolean) { + let dark = initiallyDark + const listeners = new Set<() => void>() + vi.stubGlobal('matchMedia', (query: string) => ({ + get matches() { + return query === '(prefers-color-scheme: dark)' && dark + }, + media: query, + onchange: null, + addEventListener: (_: string, l: () => void) => listeners.add(l), + removeEventListener: (_: string, l: () => void) => listeners.delete(l), + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) + return { + listeners, + set(next: boolean) { + dark = next + for (const l of listeners) l() + }, + } + } + + it('follows the system scheme when it changes while the page is open', () => { + const system = systemScheme(false) + const { unmount } = render() + expect(screen.getByRole('button', { name: 'Switch to dark theme' })).toBeInTheDocument() + act(() => system.set(true)) + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument() + act(() => system.set(false)) + expect(screen.getByRole('button', { name: 'Switch to dark theme' })).toBeInTheDocument() + expect(document.documentElement.hasAttribute('data-theme')).toBe(false) + // Unmounted, it stops listening: nothing is left subscribed to the system. + expect(system.listeners.size).toBeGreaterThan(0) + unmount() + expect(system.listeners.size).toBe(0) + }) + }) +}) diff --git a/packages/ui/src/app/ThemeToggle.tsx b/packages/ui/src/app/ThemeToggle.tsx new file mode 100644 index 0000000..9318e83 --- /dev/null +++ b/packages/ui/src/app/ThemeToggle.tsx @@ -0,0 +1,70 @@ +import { Moon, Sun } from 'lucide-react' +import { useEffect, useState, useSyncExternalStore } from 'react' + +/** Read by `public/theme.js` before first paint as well; the two must agree on this key. */ +export const THEME_KEY = 'cm-theme' + +type Choice = 'system' | 'light' | 'dark' + +function stored(): Choice { + try { + const v = localStorage.getItem(THEME_KEY) + return v === 'light' || v === 'dark' ? v : 'system' + } catch { + return 'system' + } +} + +const DARK_QUERY = '(prefers-color-scheme: dark)' + +const systemDark = () => window.matchMedia?.(DARK_QUERY).matches ?? false + +/** + * Follows the system's scheme while the page is open. Read once at render, a + * change of the system scheme would leave the button naming and drawing the + * theme the page already shows, and pressing it would change nothing. + */ +const subscribeToSystem = (onChange: () => void): (() => void) => { + const q = window.matchMedia?.(DARK_QUERY) + q?.addEventListener('change', onChange) + return () => q?.removeEventListener('change', onChange) +} + +/** + * Light or dark. The button names the theme it switches to, because an icon for + * the current one is ambiguous about which it is telling you. The choice is + * stored and applied as `data-theme`, which the tokens read; no choice leaves + * the attribute off and the system decides. + */ +export function ThemeToggle() { + const [choice, setChoice] = useState(stored) + const dark = useSyncExternalStore(subscribeToSystem, systemDark) + useEffect(() => { + const root = document.documentElement + if (choice === 'system') root.removeAttribute('data-theme') + else root.setAttribute('data-theme', choice) + try { + if (choice === 'system') localStorage.removeItem(THEME_KEY) + else localStorage.setItem(THEME_KEY, choice) + } catch { + // A browser that refuses storage keeps the choice for this page only. + } + }, [choice]) + const applied = choice === 'system' ? (dark ? 'dark' : 'light') : choice + const target = applied === 'dark' ? 'light' : 'dark' + return ( + + ) +} diff --git a/packages/ui/src/app/clock.tsx b/packages/ui/src/app/clock.tsx new file mode 100644 index 0000000..4f31543 --- /dev/null +++ b/packages/ui/src/app/clock.tsx @@ -0,0 +1,14 @@ +import { type ReactNode, createContext, useContext } from 'react' + +const NowContext = createContext<() => number>(() => Date.now()) + +/** The clock the interface reads "now" from. A test gives it a fixed one. */ +export function NowProvider({ now, children }: { now: () => number; children: ReactNode }) { + return {children} +} + +export const useNow = (): (() => number) => useContext(NowContext) +export const useNowMs = (): number => useContext(NowContext)() + +/** The browser's own zone. Every time the interface shows is in it. */ +export const browserZone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone diff --git a/packages/ui/src/app/csv.test.ts b/packages/ui/src/app/csv.test.ts new file mode 100644 index 0000000..54c6b5c --- /dev/null +++ b/packages/ui/src/app/csv.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { csvCell, toCsv } from './csv' + +// The same rules the service's own export follows: every cell quoted, a quote +// doubled, and a cell a spreadsheet would run as a formula made inert. +describe('a CSV cell', () => { + it.each([ + ['plain', '"plain"'], + ['with "quotes"', '"with ""quotes"""'], + ['a,b', '"a,b"'], + ['two\nlines', '"two\nlines"'], + ['=SUM(A1)', `"'=SUM(A1)"`], + ['+1', `"'+1"`], + ['-1', `"'-1"`], + ['@cmd', `"'@cmd"`], + [' =1', `"' =1"`], + ['\t=1', `"'\t=1"`], + ['a=b', '"a=b"'], + ])('writes %j as %s', (value, cell) => expect(csvCell(value)).toBe(cell)) + + it('writes a number as itself, not as a formula', () => expect(csvCell(-5)).toBe('"-5"')) + it('writes nothing for null', () => expect(csvCell(null)).toBe('""')) + it('writes a boolean as a word', () => expect(csvCell(true)).toBe('"true"')) +}) + +describe('a CSV file', () => { + it('ends every line with CRLF', () => { + expect( + toCsv([ + ['a', 'b'], + ['1', null], + ]), + ).toBe('"a","b"\r\n"1",""\r\n') + }) +}) diff --git a/packages/ui/src/app/csv.ts b/packages/ui/src/app/csv.ts new file mode 100644 index 0000000..8ab097c --- /dev/null +++ b/packages/ui/src/app/csv.ts @@ -0,0 +1,35 @@ +/** + * A CSV of what is already on screen — a breakdown, the chart's buckets, the + * summary — written by the browser. Every cell quoted, a quote doubled, CRLF + * line ends, no BOM, and a text cell a spreadsheet would run as a formula (a + * leading =, +, - or @, or whitespace before one) prefixed with an apostrophe + * so it is shown rather than run. Numbers are written as numbers — a count + * this page holds is not text anyone chose, and -5 must open as -5 — which is + * where this differs from the service's export: every cell there is text read + * from the store, and the service guards them all. + */ + +type Cell = string | number | boolean | null + +const FORMULA = /^[\s]*[=+\-@]/ + +export function csvCell(value: Cell): string { + if (value === null) return '""' + const text = String(value) + const safe = typeof value === 'string' && FORMULA.test(text) ? `'${text}` : text + return `"${safe.replace(/"/g, '""')}"` +} + +export function toCsv(rows: Cell[][]): string { + return rows.map((r) => `${r.map(csvCell).join(',')}\r\n`).join('') +} + +/** Saves a CSV through a temporary object URL, which is revoked straight after. */ +export function downloadCsv(filename: string, rows: Cell[][]): void { + const url = URL.createObjectURL(new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8' })) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} diff --git a/packages/ui/src/app/format.test.ts b/packages/ui/src/app/format.test.ts new file mode 100644 index 0000000..78aeca3 --- /dev/null +++ b/packages/ui/src/app/format.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + countryName, + formatAge, + formatDate, + formatInstant, + formatNumber, + formatShare, +} from './format' + +describe('numbers', () => { + it.each([ + [0, '0'], + [999, '999'], + [12345, '12,345'], + [1000000, '1,000,000'], + ])('writes %i as %s', (n, text) => expect(formatNumber(n)).toBe(text)) + + it.each([ + [42, 100, '42%'], + [1, 3, '33%'], + [1, 1000, '<1%'], + [0, 10, '0%'], + [0, 0, '–'], + [10, 10, '100%'], + ])('writes %i of %i as %s', (part, whole, text) => expect(formatShare(part, whole)).toBe(text)) +}) + +describe('instants', () => { + it('writes an instant in the zone it is given', () => { + expect(formatInstant('2026-10-07T03:00:00.000Z', 'Australia/Adelaide')).toBe( + '7 Oct 2026, 13:30', + ) + expect(formatInstant('2026-10-07T03:00:00.000Z', 'UTC')).toBe('7 Oct 2026, 03:00') + }) + + it('writes a date in the zone it is given, which can be another day', () => { + expect(formatDate('2026-10-06T20:00:00.000Z', 'Australia/Adelaide')).toBe('7 Oct 2026') + expect(formatDate('2026-10-06T20:00:00.000Z', 'UTC')).toBe('6 Oct 2026') + }) + + it.each([ + [30_000, 'just now'], + [60_000, '1 minute ago'], + [4 * 60_000, '4 minutes ago'], + [3 * 3_600_000, '3 hours ago'], + [2 * 86_400_000, '2 days ago'], + ])('writes an age of %i ms as %s', (ms, text) => expect(formatAge(0, ms)).toBe(text)) +}) + +describe('countries', () => { + it('names a country by its code', () => expect(countryName('DE')).toBe('Germany')) + it('keeps a code it cannot name', () => expect(countryName('XX')).toBe('XX')) + it('says an empty code is unknown', () => expect(countryName('')).toBe('Unknown')) +}) diff --git a/packages/ui/src/app/format.ts b/packages/ui/src/app/format.ts new file mode 100644 index 0000000..3876d7f --- /dev/null +++ b/packages/ui/src/app/format.ts @@ -0,0 +1,60 @@ +/** + * How the interface writes a number, an instant, an age and a country. Every + * screen goes through here, so that "12,345" and "7 Oct 2026, 13:30" mean the + * same thing everywhere. English, because the interface is; the zone is always + * a parameter, because the operator's is the only one that matters and the + * tests pin several. + */ + +const number = new Intl.NumberFormat('en-GB') + +export const formatNumber = (n: number): string => number.format(n) + +export function formatShare(part: number, whole: number): string { + if (whole === 0) return '–' + if (part === 0) return '0%' + const pct = (part / whole) * 100 + if (pct < 1) return '<1%' + return `${Math.round(pct)}%` +} + +export function formatInstant(iso: string, timeZone: string): string { + return new Intl.DateTimeFormat('en-GB', { + timeZone, + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(new Date(iso)) +} + +export function formatDate(iso: string, timeZone: string): string { + return new Intl.DateTimeFormat('en-GB', { + timeZone, + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(new Date(iso)) +} + +export function formatAge(fromMs: number, nowMs: number): string { + const s = Math.max(0, Math.floor((nowMs - fromMs) / 1000)) + const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'} ago` + if (s < 60) return 'just now' + if (s < 3600) return plural(Math.floor(s / 60), 'minute') + if (s < 86400) return plural(Math.floor(s / 3600), 'hour') + return plural(Math.floor(s / 86400), 'day') +} + +const regions = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'code' }) + +export function countryName(code: string): string { + if (code === '') return 'Unknown' + try { + return regions.of(code) ?? code + } catch { + return code + } +} diff --git a/packages/ui/src/app/me.tsx b/packages/ui/src/app/me.tsx new file mode 100644 index 0000000..6a422ed --- /dev/null +++ b/packages/ui/src/app/me.tsx @@ -0,0 +1,24 @@ +import type { Me } from '@/api/types' +import { type ReactNode, createContext, useContext } from 'react' + +const MeContext = createContext<{ me: Me; refreshMe: () => Promise } | null>(null) + +/** + * The signed-in admin, and how to re-read it from the service. Its own + * module because `App` imports the router and the router imports `Account`; + * a context defined in `App.tsx` would close that loop. + */ +export function MeProvider({ + me, + refreshMe, + children, +}: { me: Me; refreshMe: () => Promise; children: ReactNode }) { + return {children} +} + +/** The account this browser is signed in as. A screen rendered outside a provider is a defect, said loudly. */ +export function useMe(): { me: Me; refreshMe: () => Promise } { + const c = useContext(MeContext) + if (c === null) throw new Error('useMe outside a MeProvider') + return c +} diff --git a/packages/ui/src/app/refresh.test.tsx b/packages/ui/src/app/refresh.test.tsx new file mode 100644 index 0000000..ad0a129 --- /dev/null +++ b/packages/ui/src/app/refresh.test.tsx @@ -0,0 +1,82 @@ +import { act, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { RefreshProvider, useRefresh } from './refresh' + +/** jsdom's `visibilityState` has no setter; this stands in for the tab going away. */ +function stubVisibility(value: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { value, configurable: true }) +} + +function Round() { + const { round, refresh } = useRefresh() + return ( + + ) +} + +describe('refreshing', () => { + it('counts a press of Refresh', async () => { + render( + + + , + ) + await act(async () => screen.getByRole('button').click()) + expect(screen.getByRole('button')).toHaveTextContent('round 1') + }) + + it('refreshes on focus after a minute away, and not before', () => { + let now = 0 + render( + now}> + + , + ) + now = 59_000 + act(() => { + window.dispatchEvent(new Event('focus')) + }) + expect(screen.getByRole('button')).toHaveTextContent('round 0') + now = 61_000 + act(() => { + window.dispatchEvent(new Event('focus')) + }) + expect(screen.getByRole('button')).toHaveTextContent('round 1') + }) + + it('does not refresh a visibility change into a hidden tab, however long it was away', () => { + let now = 0 + render( + now}> + + , + ) + now = 61_000 + stubVisibility('hidden') + try { + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + } finally { + stubVisibility('visible') + } + expect(screen.getByRole('button')).toHaveTextContent('round 0') + }) + + it('stops listening once unmounted', () => { + const windowRemove = vi.spyOn(window, 'removeEventListener') + const documentRemove = vi.spyOn(document, 'removeEventListener') + const { unmount } = render( + + + , + ) + unmount() + expect(windowRemove).toHaveBeenCalledWith('focus', expect.any(Function)) + expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + windowRemove.mockRestore() + documentRemove.mockRestore() + }) +}) diff --git a/packages/ui/src/app/refresh.tsx b/packages/ui/src/app/refresh.tsx new file mode 100644 index 0000000..6fc2c0f --- /dev/null +++ b/packages/ui/src/app/refresh.tsx @@ -0,0 +1,53 @@ +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +const RefreshContext = createContext<{ round: number; refresh: () => void }>({ + round: 0, + refresh: () => {}, +}) + +/** How long away before coming back to the tab reloads what is on screen. */ +export const FOCUS_REFRESH_AFTER_MS = 60_000 + +/** + * One counter every screen's loads depend on. Refresh increments it, and so + * does coming back to the tab after a minute away — never a timer, because a + * timer keeps a session alive for as long as a tab is open and spends report + * slots on a page nobody is reading. + */ +export function RefreshProvider({ + children, + now = Date.now, + focusAfterMs = FOCUS_REFRESH_AFTER_MS, +}: { children: ReactNode; now?: () => number; focusAfterMs?: number }) { + const [round, setRound] = useState(0) + const last = useRef(now()) + const refresh = useCallback(() => { + last.current = now() + setRound((r) => r + 1) + }, [now]) + useEffect(() => { + const back = () => { + if (document.visibilityState === 'hidden') return + if (now() - last.current > focusAfterMs) refresh() + } + window.addEventListener('focus', back) + document.addEventListener('visibilitychange', back) + return () => { + window.removeEventListener('focus', back) + document.removeEventListener('visibilitychange', back) + } + }, [now, focusAfterMs, refresh]) + const value = useMemo(() => ({ round, refresh }), [round, refresh]) + return {children} +} + +export const useRefresh = () => useContext(RefreshContext) diff --git a/packages/ui/src/app/useLoad.test.tsx b/packages/ui/src/app/useLoad.test.tsx new file mode 100644 index 0000000..53cbb9d --- /dev/null +++ b/packages/ui/src/app/useLoad.test.tsx @@ -0,0 +1,127 @@ +import { ApiError } from '@/api/errors' +import { act, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useLoad } from './useLoad' + +function Show({ load, dep = 0 }: { load: (s: AbortSignal) => Promise; dep?: number }) { + const r = useLoad(load, [dep]) + return ( +
+ {r.state} + {r.data ?? ''} + {r.error?.code ?? ''} + +
+ ) +} + +describe('loading', () => { + it('shows what it loaded', async () => { + render( Promise.resolve('numbers')} />) + expect(await screen.findByText('numbers')).toBeInTheDocument() + expect(screen.getByTestId('state')).toHaveTextContent('ok') + }) + + it('shows the error it got', async () => { + render( Promise.reject(new ApiError(503, 'reporting_unavailable', 'down'))} />) + expect(await screen.findByText('reporting_unavailable')).toBeInTheDocument() + expect(screen.getByTestId('state')).toHaveTextContent('error') + }) + + // A screen the operator left, or a window they changed: the answer to the + // old request must not replace the new one's, and its abort is not an error. + it('abandons the old request when what it depends on changes, and shows only the new answer', async () => { + const signals: AbortSignal[] = [] + let finishOld: (v: string) => void = () => {} + const load = vi.fn((s: AbortSignal) => { + signals.push(s) + return signals.length === 1 + ? new Promise((r) => { + finishOld = r + }) + : Promise.resolve('new') + }) + const { rerender } = render() + rerender() + expect(await screen.findByText('new')).toBeInTheDocument() + expect(signals[0]?.aborted).toBe(true) + await act(async () => finishOld('old')) + expect(screen.getByTestId('data')).toHaveTextContent('new') + expect(screen.getByTestId('error')).toHaveTextContent('') + }) + + // A load that rejects with an abort while its own signal is live — a fetch + // cancelled by something other than this hook. It is not the service + // refusing, and it is not shown as one. + it('does not show an abort it did not cause as an error', async () => { + render( Promise.reject(new DOMException('cancelled', 'AbortError'))} />) + await new Promise((r) => setTimeout(r, 0)) + expect(screen.getByTestId('state')).toHaveTextContent('loading') + expect(screen.getByTestId('error')).toHaveTextContent('') + }) + + // A load that failed, then what it depends on changes: the new attempt is + // not the one that failed, and must not be shown as already having failed + // before it has even answered. + it('clears a previous failure once a new load — from changed deps — starts', async () => { + let calls = 0 + const load = vi.fn(() => { + calls += 1 + if (calls === 1) return Promise.reject(new ApiError(503, 'reporting_unavailable', 'down')) + return new Promise(() => {}) + }) + const { rerender } = render() + expect(await screen.findByText('reporting_unavailable')).toBeInTheDocument() + rerender() + expect(screen.getByTestId('state')).toHaveTextContent('loading') + expect(screen.getByTestId('error')).toHaveTextContent('') + }) + + // The last success is not "the data" any more once the service has since + // refused: a reload after a failure must not bring the old answer back + // while the new attempt is still in flight — a screen that renders on + // `data` alone (rather than on `state`) would show it as current again. + it('clears the previous data once a new load starts after a failure', async () => { + let n = 0 + let resolveThird: (v: string) => void = () => {} + const load = vi.fn(() => { + n += 1 + if (n === 1) return Promise.resolve('load 1') + if (n === 2) return Promise.reject(new ApiError(404, 'not_found', 'no such thing')) + return new Promise((r) => { + resolveThird = r + }) + }) + render() + expect(await screen.findByText('load 1')).toBeInTheDocument() + await act(async () => screen.getByRole('button', { name: 'again' }).click()) + expect(await screen.findByText('not_found')).toBeInTheDocument() + await act(async () => screen.getByRole('button', { name: 'again' }).click()) + expect(screen.getByTestId('state')).toHaveTextContent('loading') + expect(screen.getByTestId('data')).toHaveTextContent('') + expect(screen.getByTestId('error')).toHaveTextContent('') + await act(async () => resolveThird('load 3')) + }) + + it('loads again when asked, staying in loading with the old data until the new answer arrives', async () => { + let n = 0 + let resolveSecond: (v: string) => void = () => {} + const load = vi.fn(() => { + n += 1 + if (n === 1) return Promise.resolve('load 1') + return new Promise((r) => { + resolveSecond = r + }) + }) + render() + expect(await screen.findByText('load 1')).toBeInTheDocument() + await act(async () => screen.getByRole('button', { name: 'again' }).click()) + expect(screen.getByTestId('state')).toHaveTextContent('loading') + expect(screen.getByTestId('data')).toHaveTextContent('load 1') + await act(async () => resolveSecond('load 2')) + expect(screen.getByTestId('state')).toHaveTextContent('ok') + expect(screen.getByTestId('data')).toHaveTextContent('load 2') + }) +}) diff --git a/packages/ui/src/app/useLoad.ts b/packages/ui/src/app/useLoad.ts new file mode 100644 index 0000000..a4df100 --- /dev/null +++ b/packages/ui/src/app/useLoad.ts @@ -0,0 +1,64 @@ +import { ApiError } from '@/api/errors' +import { useCallback, useEffect, useState } from 'react' + +type Loaded = { + state: 'loading' | 'ok' | 'error' + data: T | undefined + error: ApiError | undefined +} + +/** + * One request's life on a screen: loading, then the answer or the error. + * + * Each load gets its own signal, aborted when what it depends on changes or the + * screen goes away, so an answer to a request nobody is waiting for can never + * replace the one that is — the window changed from 7 days to 30, and the + * 7-day answer arrives second. An abort is not an error and is never shown. + * An error that is not an `ApiError` is a defect in this package and is + * rethrown to React rather than shown as a service's refusal. + * + * The previous data stays visible while a reload is in flight, so Refresh does + * not blank the screen. The previous error does not: a load that starts is a + * fresh attempt, and showing the last one's failure while this one is still + * running would blame the wrong request once it succeeds. A load that starts + * after a failure has no previous answer to hold onto either — the last + * success is not "the data" any more once the service has since refused, so a + * reload after an error goes back to loading with nothing shown, the same as + * a screen's first load. + */ +export function useLoad(load: (signal: AbortSignal) => Promise, deps: unknown[]) { + const [loaded, setLoaded] = useState>({ + state: 'loading', + data: undefined, + error: undefined, + }) + const [round, setRound] = useState(0) + const [thrown, setThrown] = useState(null) + if (thrown !== null) throw thrown + + // biome-ignore lint/correctness/useExhaustiveDependencies: `deps` is the caller's list of what this load reads; `load` is a new function every render and must not be one. + useEffect(() => { + const controller = new AbortController() + setLoaded((l) => ({ + state: 'loading', + data: l.state === 'error' ? undefined : l.data, + error: undefined, + })) + load(controller.signal).then( + (data) => { + if (!controller.signal.aborted) setLoaded({ state: 'ok', data, error: undefined }) + }, + (err: unknown) => { + if (controller.signal.aborted) return + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) + setLoaded((l) => ({ state: 'error', data: l.data, error: err })) + else setThrown(err) + }, + ) + return () => controller.abort() + }, [...deps, round]) + + const reload = useCallback(() => setRound((r) => r + 1), []) + return { ...loaded, reload } +} diff --git a/packages/ui/src/build-output.test.ts b/packages/ui/src/build-output.test.ts new file mode 100644 index 0000000..d72726a --- /dev/null +++ b/packages/ui/src/build-output.test.ts @@ -0,0 +1,89 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' + +const DIST = join(import.meta.dirname, '..', 'dist') + +let html = '' +let css = '' + +beforeAll(() => { + if (!existsSync(join(DIST, 'index.html'))) + throw new Error( + 'run `pnpm --filter @clickmonk/ui build` first: this test reads the built output', + ) + html = readFileSync(join(DIST, 'index.html'), 'utf8') + const assets = readdirSync(join(DIST, 'assets')) + const js = assets.filter((f) => f.endsWith('.js')) + expect( + js, + 'one chunk: nothing is lazy-loaded, so an upgrade cannot leave a page asking for a chunk that is gone', + ).toHaveLength(1) + const cssFile = assets.find((f) => f.endsWith('.css')) + if (!cssFile) throw new Error('no built CSS file in dist/assets') + css = readFileSync(join(DIST, 'assets', cssFile), 'utf8') +}) + +describe('the built stylesheet', () => { + // Tailwind's preflight resets `border` to `0 solid`, which leaves the + // colour at its default, currentColor. Without a base rule pointing every + // border at the brand's role, a bare `border` class (the card, the + // outline button, the table's rules) draws in whatever text colour is + // inherited, not a border. + it('gives every element a border colour from the brand role, not currentColor', () => { + expect(css).toContain('border-color:var(--color-border)') + }) +}) + +describe('the built page', () => { + it('carries no inline script, no style element and no style attribute', () => { + const scripts = [...html.matchAll(/]*>/g)].map((m) => m[0]) + expect(scripts.every((s) => /\bsrc=/.test(s))).toBe(true) + expect(scripts).toHaveLength(2) + expect(html).not.toMatch(/ { + expect(html.indexOf('/theme.js')).toBeGreaterThan(-1) + expect(html.indexOf('/theme.js')).toBeLessThan(html.indexOf('/assets/')) + }) + + it('applies a stored dark theme when the theme script runs', () => { + localStorage.setItem('cm-theme', 'dark') + new Function(readFileSync(join(DIST, 'theme.js'), 'utf8'))() + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + }) + + it('leaves the attribute off when nothing is stored, or something unknown is', () => { + // Nothing stored: test-setup's beforeEach already cleared localStorage. + new Function(readFileSync(join(DIST, 'theme.js'), 'utf8'))() + expect(document.documentElement.hasAttribute('data-theme')).toBe(false) + + localStorage.setItem('cm-theme', 'purple') + new Function(readFileSync(join(DIST, 'theme.js'), 'utf8'))() + expect(document.documentElement.hasAttribute('data-theme')).toBe(false) + }) + + // A module pulled into the bundle with a top-level side effect — a service + // package's crypto setup — throws the moment the bundle is evaluated, and the + // page is blank. Tree-shaking cannot prove a top-level call harmless, and a + // grep of the minified bundle cannot find it by name. So the real built + // bundle is evaluated here, and must render something. + it('evaluates without throwing, and renders', async () => { + const modules = import.meta.glob('../dist/assets/*.js') + const [load] = Object.values(modules) + if (!load) throw new Error('no built bundle') + document.body.innerHTML = '
' + const fetch = globalThis.fetch + globalThis.fetch = () => new Promise(() => {}) + try { + await load() + // React 19 schedules its first commit, so the tree is there a tick later. + await new Promise((r) => setTimeout(r, 0)) + expect(document.getElementById('root')?.children.length).toBeGreaterThan(0) + } finally { + globalThis.fetch = fetch + } + }) +}) diff --git a/packages/ui/src/charts/Bars.tsx b/packages/ui/src/charts/Bars.tsx new file mode 100644 index 0000000..9a217fa --- /dev/null +++ b/packages/ui/src/charts/Bars.tsx @@ -0,0 +1,31 @@ +/** + * Horizontal bars for a breakdown: each row's label, its bar as a share of the + * largest row, and its numbers. The bar's width is a React style, which the + * browser sets through the CSSOM rather than as an inline style attribute, so + * the security policy does not govern it. + */ +export function Bars({ + rows, +}: { rows: { key: string; label: string; value: number; secondary: string }[] }) { + const max = Math.max(1, ...rows.map((r) => r.value)) + return ( +
    + {rows.map((r) => ( +
  • +
    + + {r.label} + + {r.secondary} +
    +
    +
    +
    +
  • + ))} +
+ ) +} diff --git a/packages/ui/src/charts/Columns.test.tsx b/packages/ui/src/charts/Columns.test.tsx new file mode 100644 index 0000000..1851511 --- /dev/null +++ b/packages/ui/src/charts/Columns.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { Columns } from './Columns' + +const buckets = [ + { label: 'Mon 5 Oct', value: 12 }, + { label: 'Tue 6 Oct', value: 0 }, + { label: 'Wed 7 Oct', value: 37 }, +] + +describe('the column chart', () => { + it('says what it shows in one sentence', () => { + render() + expect( + screen.getByRole('img', { + name: 'Clicks by day: 49 clicks over 3 days, the most 37 on Wed 7 Oct', + }), + ).toBeInTheDocument() + }) + + // Visitors are counted per bar, so their total across bars is not a real + // number: the sentence names the buckets and the peak, never a sum, and + // says nothing where the clicks sentence would have said "49 visitors". + it('never sums the buckets for visitors: the sentence names the peak, not a total', () => { + render() + expect( + screen.getByRole('img', { + name: 'Visitors by day, over 3 days, the most 37 on Wed 7 Oct', + }), + ).toBeInTheDocument() + }) + + it('draws every bucket, the empty one included', () => { + const { container } = render( + , + ) + expect(container.querySelectorAll('rect[data-bucket]')).toHaveLength(3) + expect(container.querySelector('rect[data-bucket="1"] title')).toHaveTextContent( + 'Tue 6 Oct: 0 clicks', + ) + }) + + it('labels its axis from zero to a round maximum', () => { + render() + const axis = screen.getByTestId('axis') + expect( + within(axis) + .getAllByText(/\d/) + .map((e) => e.textContent), + ).toEqual(['50', '25', '0']) + }) + + // jsdom does not hide a closed
from the accessibility queries, so + // what the click changes is asserted directly: the disclosure is shut, then + // open. + it('shows the numbers in a table on request', async () => { + render() + const disclosure = screen.getByText('Show the numbers').closest('details') as HTMLDetailsElement + expect(disclosure.open).toBe(false) + await userEvent.setup().click(screen.getByText('Show the numbers')) + expect(disclosure.open).toBe(true) + const rows = within(screen.getByRole('table')) + .getAllByRole('row') + .map((r) => r.textContent) + expect(rows).toEqual(['WhenClicks', 'Mon 5 Oct12', 'Tue 6 Oct0', 'Wed 7 Oct37']) + }) + + // Two buckets can share a label (an hour chart's clock-change "again"), so + // the table's rows are keyed by position, not by the label: React warns to + // the console on a duplicate key, and this asserts it never does. + it('never warns of a duplicate key when two buckets share a label', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const same = [ + { label: '01:00', value: 3 }, + { label: '01:00', value: 5 }, + ] + render() + await userEvent.setup().click(screen.getByText('Show the numbers')) + expect(within(screen.getByRole('table')).getAllByRole('row')).toHaveLength(3) + expect(error).not.toHaveBeenCalled() + error.mockRestore() + }) +}) diff --git a/packages/ui/src/charts/Columns.tsx b/packages/ui/src/charts/Columns.tsx new file mode 100644 index 0000000..d64ee02 --- /dev/null +++ b/packages/ui/src/charts/Columns.tsx @@ -0,0 +1,116 @@ +import { formatNumber } from '@/app/format' +import { columns } from './columns' + +const BOX = { width: 600, height: 160, gap: 2 } + +/** + * Columns over time. Drawn by hand from `columns()`; every rectangle carries + * its label and value as a native tooltip, the chart as a whole a sentence for + * a screen reader, and the same numbers are in a table on request. + * + * Coloured from the theme only: `var(--color-primary)` for the columns and the + * border role for the axis, which is what makes it follow the theme and pass + * the source rules. + */ +export function Columns({ + buckets, + unit, + label, + span, +}: { + buckets: { label: string; value: number }[] + unit: 'clicks' | 'visitors' + label: string + span: 'hour' | 'day' +}) { + const g = columns( + buckets.map((b) => b.value), + BOX, + ) + const total = buckets.reduce((n, b) => n + b.value, 0) + const top = buckets.reduce<{ label: string; value: number } | null>( + (m, b) => (m === null || b.value > m.value ? b : m), + null, + ) + const most = top && top.value > 0 ? `, the most ${formatNumber(top.value)} on ${top.label}` : '' + const spans = `over ${buckets.length} ${span}${buckets.length === 1 ? '' : 's'}` + // Visitors are counted per bar (a returning visitor is one visitor in each + // bar they appear in), so a total across bars would be wrong in the same + // way the caveat under the chart warns against — the sentence names the + // buckets and the peak, never a sum, when the metric is visitors. + const sentence = + unit === 'visitors' + ? `${label}, ${spans}${most}` + : `${label}: ${formatNumber(total)} ${unit} ${spans}${most}` + const Unit = unit === 'clicks' ? 'Clicks' : 'Visitors' + return ( +
+
+
+ {formatNumber(g.max)} + {formatNumber(g.max / 2)} + 0 +
+ + + {g.bars.map((b, i) => ( + + {`${buckets[i]?.label}: ${formatNumber(buckets[i]?.value ?? 0)} ${unit}`} + + ))} + +
+
+ {buckets[0]?.label} + {buckets[buckets.length - 1]?.label} +
+
+ Show the numbers + + + + + + + + + {buckets.map((b, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: buckets are positional and never reorder; two buckets can share a label (a clock change), and the label is not a stable identity. + + + + + ))} + +
+ When + {Unit}
{b.label}{formatNumber(b.value)}
+
+
+ ) +} diff --git a/packages/ui/src/charts/columns.test.ts b/packages/ui/src/charts/columns.test.ts new file mode 100644 index 0000000..b45dc80 --- /dev/null +++ b/packages/ui/src/charts/columns.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { columns, niceMax } from './columns' + +describe('the axis maximum', () => { + it.each([ + [0, 1], + [1, 1], + [2, 2], + [3, 5], + [7, 10], + [37, 50], + [100, 100], + [101, 200], + [4999, 5000], + [5001, 10000], + ])('for a largest value of %i is %i', (v, max) => expect(niceMax(v)).toBe(max)) +}) + +describe('columns', () => { + const box = { width: 100, height: 50, gap: 2 } + + it('draws one column per bucket, a zero as a column of no height on the baseline', () => { + const r = columns([10, 0, 5], box) + expect(r.max).toBe(10) + // Three slots of 33.33 wide, each column 2 narrower than its slot. + expect(r.bars.map((b) => [round(b.x), round(b.width), round(b.y), round(b.height)])).toEqual([ + [1, 31.33, 0, 50], + [34.33, 31.33, 50, 0], + [67.67, 31.33, 25, 25], + ]) + }) + + it('draws an all-zero chart as zero columns under an axis of one', () => { + const r = columns([0, 0], box) + expect(r.max).toBe(1) + expect(r.bars.map((b) => b.height)).toEqual([0, 0]) + }) + + it('draws nothing for no buckets', () => { + expect(columns([], box)).toEqual({ max: 1, bars: [] }) + }) + + it('never draws a column narrower than nothing when the gap is wider than a slot', () => { + const r = columns(new Array(100).fill(1), { width: 100, height: 10, gap: 2 }) + expect(Math.min(...r.bars.map((b) => b.width))).toBe(0) + }) +}) + +const round = (n: number) => Math.round(n * 100) / 100 diff --git a/packages/ui/src/charts/columns.ts b/packages/ui/src/charts/columns.ts new file mode 100644 index 0000000..d72452e --- /dev/null +++ b/packages/ui/src/charts/columns.ts @@ -0,0 +1,37 @@ +/** + * Column chart geometry, as a pure function so it can be tested without a + * browser: one rectangle per value, in a box, on a linear scale to a round + * maximum. + * + * Every value is drawn, a zero as a column of no height on the baseline. The + * service fills the buckets nothing happened in with zeros, and a chart that + * dropped them would draw an outage as traffic that carried on. + * + * Linear, because an operator compares these counts against each other and + * against a cap, and a compressed scale misleads on exactly that comparison. + */ + +/** The smallest of 1, 2 or 5 times a power of ten at or above `v`. At least 1. */ +export function niceMax(v: number): number { + if (v <= 1) return 1 + const p = 10 ** Math.floor(Math.log10(v)) + for (const m of [1, 2, 5, 10]) if (m * p >= v) return m * p + return 10 * p +} + +export function columns( + values: number[], + box: { width: number; height: number; gap: number }, +): { max: number; bars: { x: number; y: number; width: number; height: number }[] } { + const max = niceMax(Math.max(0, ...values)) + if (values.length === 0) return { max, bars: [] } + const slot = box.width / values.length + const width = Math.max(0, slot - box.gap) + return { + max, + bars: values.map((v, i) => { + const height = (v / max) * box.height + return { x: i * slot + (slot - width) / 2, y: box.height - height, width, height } + }), + } +} diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx new file mode 100644 index 0000000..bf17139 --- /dev/null +++ b/packages/ui/src/components/ui/badge.tsx @@ -0,0 +1,45 @@ +import { cn } from '@/lib/utils' +import { type VariantProps, cva } from 'class-variance-authority' +import { Slot } from 'radix-ui' +import type * as React from 'react' + +const badgeVariants = cva( + 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'bg-destructive text-destructive-foreground focus-visible:ring-ring [a&]:hover:bg-destructive/90', + outline: + 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + link: 'text-primary underline-offset-4 [a&]:hover:underline', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx new file mode 100644 index 0000000..00b733e --- /dev/null +++ b/packages/ui/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/lib/utils' +import { type VariantProps, cva } from 'class-variance-authority' +import { Slot } from 'radix-ui' +import type * as React from 'react' + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-ring', + outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : 'button' + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx new file mode 100644 index 0000000..be2a467 --- /dev/null +++ b/packages/ui/src/components/ui/card.tsx @@ -0,0 +1,72 @@ +import { cn } from '@/lib/utils' +import type * as React from 'react' + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +// A heading, not a div: a card titles a section of the page, and a card +// grid is how an operator navigates it. It is never the page's own

. +function CardTitle({ className, ...props }: React.ComponentProps<'h2'>) { + return ( +

+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/packages/ui/src/components/ui/checkbox.tsx b/packages/ui/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..ece0e2e --- /dev/null +++ b/packages/ui/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils' +import { CheckIcon } from 'lucide-react' +import { Checkbox as CheckboxPrimitive } from 'radix-ui' +import type * as React from 'react' + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx new file mode 100644 index 0000000..0fc1bf6 --- /dev/null +++ b/packages/ui/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/lib/utils' +import type * as React from 'react' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ) +} + +export { Input } diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx new file mode 100644 index 0000000..96f2462 --- /dev/null +++ b/packages/ui/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import { cn } from '@/lib/utils' +import { Label as LabelPrimitive } from 'radix-ui' +import type * as React from 'react' + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/packages/ui/src/components/ui/modal.test.tsx b/packages/ui/src/components/ui/modal.test.tsx new file mode 100644 index 0000000..9fa721b --- /dev/null +++ b/packages/ui/src/components/ui/modal.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { Modal } from './modal' + +describe('the dialog while closed', () => { + it('renders nothing inside it', () => { + render( + {}} title="Secret"> +

secret

+
, + ) + expect(screen.queryByText('secret')).not.toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/ui/modal.tsx b/packages/ui/src/components/ui/modal.tsx new file mode 100644 index 0000000..570fdc5 --- /dev/null +++ b/packages/ui/src/components/ui/modal.tsx @@ -0,0 +1,76 @@ +import { cn } from '@/lib/utils' +import { type ReactNode, useEffect, useId, useRef } from 'react' + +/** + * A modal on the browser's own , opened with showModal(): focus moves + * into it, the page behind it is inert, and Escape closes it — all without a + * line of script of ours and without a style element, which is why this is not + * the component library's dialog (that one injects a