Gate blocked actions behind an in-place terms acceptance dialog

A member whose account has not accepted the terms in force now gets one
dialog on the action they attempted instead of a dead-end refusal. The
client handler is the single TermsGate, wired into every Http POST helper
so the four optimistic controllers cannot swallow the gate into an error
flash, and the original request is replayed once the acceptance is
recorded. Reading the site and deleting an account stay unblocked.

apple.md is the source brief the compliance research documents reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:25:57 +02:00
co-authored by Claude Opus 5
parent 8e9d3fad98
commit 91fac7fd67
38 changed files with 393 additions and 58 deletions
+3
View File
@@ -43,6 +43,9 @@ A small set of plain ES6 modules under `static/js/` own the cross-cutting patter
Detail on each utility:
- **`Http` (`static/js/Http.js`, global `window.Http`).** The single HTTP helper. `getJson(url)` (GET -> JSON, throws on non-2xx); `sendForm(url, params)` (POST form-encoded, follows the `/auth/login` redirect via `Http.toLogin()`, throws a bare status on failure, returns JSON); `send(url, params)` (POST form-encoded that throws `data.error.message` on `!ok` **or** a 200 body with `ok:false` - the manager-style error the container/admin UIs surface in a toast); `postJson`/`postForm`/`toLogin`. Container files (`ContainerManager`, `ContainerList`, `ContainerInstance`, `ContainerTerminal`), `ServiceMonitor`, and `ProjectFiles` all route through it - none re-implement `fetch`.
`Http.suspend()` is the named "we are resolving this elsewhere, do not let the caller render an error" idiom (a promise that never settles). It replaced three inline `new Promise(() => {})` copies and is what `toLogin()` and the terms gate both return.
- **`TermsGate` (`static/js/TermsGate.js`, `app.termsGate`).** The single client-side handler for the terms-acceptance refusal. `Http` calls `Http._gate(data, options, retry)` on **every** POST helper (`sendForm`, `send`, `postJson`); when the error payload carries `code: "terms_acceptance_required"` it hands off to `app.termsGate.intercept(error, retry)`, which shows one dialog (Accept and continue / Not now, with the Terms, Guidelines and Privacy links), POSTs `/auth/accept-terms` on accept, and then **re-runs the original request** so the click the user made actually happens. Declining returns `Http.suspend()`.
Load-bearing details: the handoff runs **before** the `options.silent` check, because the four `OptimisticAction` controllers (vote/react/bookmark/poll) pass `silent: true` and would otherwise swallow a blocking gate into a 1.5s "Error" flash; `options.termsRetry` bounds the retry to exactly one pass; and `confirm()`/`accept()` are each deduped by a stored promise so N concurrent gated requests produce one dialog and one acceptance POST. `dp-upload` bypasses `Http` (it needs `FormData`), so it checks `app.termsGate.matches(data)` itself - any other raw-`fetch` caller must do the same. Never add a per-caller terms check: the backend contract lives in `routers/auth/terms.py` `TERMS_ACCEPTANCE_CODE` and is documented in `devplacepy/services/moderation/CLAUDE.md`.
- **`Poller` (`static/js/Poller.js`).** `new Poller(fn, intervalMs, { immediate = true, pauseHidden = false })` runs `fn` on an interval with `start()`/`stop()`/`tick()`; `tick()` swallows errors so one failed poll never kills the loop, and `pauseHidden` skips the tick while `document.hidden`. Used by every live-update loop: `CounterManager` (30s, `pauseHidden`), `ContainerManager` (3s), `ContainerList` (4s), `AiUsageMonitor`, `ServiceMonitor`, and `ContainerInstance`'s detail (4s) + logs (3s). Store the `Poller`, not a raw interval id.
- **`JobPoller` (`static/js/JobPoller.js`).** `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 })` returns a Promise; it polls `Http.getJson(statusUrl)`, swallows transient fetch errors, and fires the matching callback on `status === "done"|"failed"` or timeout. This is the one place the async-job status-poll lives - `ProjectForker` and `ZipDownloader` both call it with their own navigate/download/toast callbacks.
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.