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
+14
View File
@@ -27,6 +27,20 @@
word-break: break-word;
}
.dialog-links {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--space-sm) var(--space-md);
margin: 0 0 1rem;
padding: 0;
font-size: 0.875rem;
}
.dialog-links a {
color: var(--accent);
}
.dialog-field {
margin-bottom: 1rem;
}
+2
View File
@@ -34,6 +34,7 @@ import { IssueAttachments } from "./IssueAttachments.js";
import { PlanningGenerator } from "./PlanningGenerator.js";
import { MediaGallery } from "./MediaGallery.js";
import { ReportDialog } from "./ReportDialog.js";
import { TermsGate } from "./TermsGate.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
@@ -69,6 +70,7 @@ class Application {
this.toast = document.createElement("dp-toast");
this.lightbox = document.createElement("dp-lightbox");
document.body.append(this.dialog, this.contextMenu, this.toast, this.lightbox);
this.termsGate = new TermsGate(this.dialog);
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
+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.
+28 -5
View File
@@ -6,6 +6,17 @@ export class Http {
window.location.href = `/auth/login?next=${next}`;
}
static suspend() {
return new Promise(() => {});
}
static _gate(data, options, retry) {
if (options.termsRetry) return null;
const gate = window.app && window.app.termsGate;
if (!gate || !gate.matches(data)) return null;
return gate.intercept(data.error, retry);
}
static notifyError(message) {
const text = (message && String(message).trim()) || "Something went wrong. Please try again.";
const app = window.app;
@@ -54,10 +65,14 @@ export class Http {
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
return Http.suspend();
}
if (!response.ok) {
const { message } = await Http._messageFrom(response);
const { data, message } = await Http._messageFrom(response);
const gate = Http._gate(data, options, () =>
Http.sendForm(url, params, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
@@ -75,11 +90,15 @@ export class Http {
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
return Http.suspend();
}
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
const message = (data.error && data.error.message) || `request failed: ${response.status}`;
const gate = Http._gate(data, options, () =>
Http.send(url, params, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
@@ -94,10 +113,14 @@ export class Http {
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
return Http.suspend();
}
if (!response.ok) {
const { message } = await Http._messageFrom(response);
const { data, message } = await Http._messageFrom(response);
const gate = Http._gate(data, options, () =>
Http.postJson(url, body, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw Http._error(message, response.status);
}
+79
View File
@@ -0,0 +1,79 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export const TERMS_ACCEPTANCE_CODE = "terms_acceptance_required";
const ACCEPT_URL = "/auth/accept-terms";
const TERMS_LINKS = [
{ href: "/docs/terms.html", label: "Terms of Service" },
{ href: "/docs/community-guidelines.html", label: "Community Guidelines" },
{ href: "/docs/privacy.html", label: "Privacy Policy" },
];
export class TermsGate {
constructor(dialog) {
this.dialog = dialog;
this.prompted = null;
this.accepted = null;
}
matches(payload) {
const error = payload && payload.error;
return !!error && error.code === TERMS_ACCEPTANCE_CODE;
}
async intercept(error, retry) {
if (!(await this.confirm(error))) {
return Http.suspend();
}
if (!(await this.accept())) {
return Http.suspend();
}
return retry();
}
confirm(error) {
if (!this.prompted) {
this.prompted = this.dialog
.confirm({
title: "Terms of Service",
message: TermsGate.message(error),
links: TERMS_LINKS,
confirmLabel: "Accept and continue",
cancelLabel: "Not now",
})
.then((answer) => {
this.prompted = null;
return answer === true;
});
}
return this.prompted;
}
accept() {
if (!this.accepted) {
this.accepted = Http.sendForm(ACCEPT_URL, {}, { silent: true, termsRetry: true })
.then(() => true)
.catch(() => {
Http.notifyError("Your acceptance could not be recorded. Please try again.");
return false;
})
.finally(() => {
this.accepted = null;
});
}
return this.accepted;
}
static message(error) {
const version = error && error.terms_version;
const subject = version ? `Version ${version} of the terms` : "The terms";
return (
`${subject} is in force and your account has not accepted it yet, ` +
"so this action was not carried out. Accepting records your agreement and repeats the action. " +
"Reading the site and deleting your account are never blocked."
);
}
}
@@ -24,6 +24,7 @@ export class AppDialog extends Component {
'<div class="modal-header"><h3 class="dialog-title"></h3>' +
'<button type="button" class="modal-close btn-ghost btn-icon dialog-close">&times;</button></div>' +
'<p class="dialog-message"></p>' +
'<ul class="dialog-links" hidden></ul>' +
'<div class="dialog-field" hidden><label class="dialog-field-label"></label>' +
'<input type="text" class="dialog-input" autocomplete="off"></div>' +
'<div class="modal-footer">' +
@@ -40,6 +41,7 @@ export class AppDialog extends Component {
this.messageEl.id = `dialog-message-${uid}`;
overlay.setAttribute("aria-labelledby", this.titleEl.id);
overlay.setAttribute("aria-describedby", this.messageEl.id);
this.links = overlay.querySelector(".dialog-links");
this.field = overlay.querySelector(".dialog-field");
this.fieldLabel = overlay.querySelector(".dialog-field-label");
this.input = overlay.querySelector(".dialog-input");
@@ -94,6 +96,7 @@ export class AppDialog extends Component {
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
this.cancelBtn.style.display = mode === "alert" ? "none" : "";
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
this.renderLinks(opts.links);
if (mode === "prompt") {
this.field.hidden = false;
@@ -116,6 +119,22 @@ export class AppDialog extends Component {
return new Promise((resolve) => { this.resolver = resolve; });
}
renderLinks(links) {
const items = Array.isArray(links) ? links : [];
this.links.replaceChildren();
this.links.hidden = !items.length;
for (const item of items) {
const anchor = document.createElement("a");
anchor.href = item.href;
anchor.textContent = item.label;
anchor.target = "_blank";
anchor.rel = "noopener";
const row = document.createElement("li");
row.appendChild(anchor);
this.links.appendChild(row);
}
}
accept() {
const value = this.mode === "prompt" ? this.input.value : true;
this.close(value);
+5 -1
View File
@@ -163,7 +163,7 @@ export class AppUpload extends Component {
return true;
}
async upload(file) {
async upload(file, termsRetry = false) {
this.button.classList.add("uploading");
this.setBusy(true);
const body = new FormData();
@@ -179,6 +179,10 @@ export class AppUpload extends Component {
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false || data.error) {
const gate = window.app && window.app.termsGate;
if (!termsRetry && gate && gate.matches(data)) {
return gate.intercept(data.error, () => this.upload(file, true));
}
const message = (data.error && (data.error.message || data.error)) || "Upload failed";
throw new Error(message);
}