diff --git a/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md b/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md new file mode 100644 index 0000000..85cf9c7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md @@ -0,0 +1,214 @@ +# Captive portal splash designer + +**Date:** 2026-08-21 +**Repos:** `central-unifi` (routes, controller, model, migration), `dashboard-shell` (editor page, Blade splash view, tests) +**Status:** design approved, no implementation plan yet +**Sequencing:** queues behind the six open findings in +`docs/superpowers/reviews/2026-08-21-plan1-review.md`. Nothing here depends on +them, but they touch the same controller. + +--- + +## What this is + +Let an operator lay out what a pupil sees at the captive portal, with the device's +own facts available as variables — IP, MAC, browser, machine type — rather than the +four hardcoded strings the portal has today. + +## What already exists and is being reused + +| Need | Existing thing | +|---|---| +| `{token}` substitution + `{if}/{else}` conditionals | `App\Support\TemplateRender::apply()` — already used by signatures, NoE PDFs, payment receipts | +| Value escaping | the same method's `$escapeValues` parameter; signatures already pass it | +| Grouped variable sidebar | `TemplateRender`'s helper that turns a token→label map into grouped, alphabetised lists for a template editor | +| Live preview | the JS mirror `resources/js/utils/template.js` (`applyTemplate`) | +| Visual, source-free editing | the shared EmailComposer's anatomy (visual-only, no HTML source) | +| Device type from a User-Agent | `ActiveSessionController::device()` — already yields "Safari · iPhone", and its OS table separates iPhone/iPad/Android from the desktop platforms | + +So this is mostly assembly. The genuinely new parts are a public Blade route, a +templates table, and the editor screen. + +**Prerequisite:** `ActiveSessionController::device()` is a private method on a +controller. Extract it to `App\Support\UserAgent::describe()` and have the Logged-in +Users page and the portal both call it. This is also finding 3a of the Plan 1 +review, which wants the same extraction for a different reason. + +--- + +## Decisions + +| # | Decision | Rationale | +|---|---|---| +| 1 | The designer covers **four** templates: splash, success, over-limit, refused | The after-login pages are where a pupil is actually stuck, and they can use quota facts the splash cannot | +| 2 | **Visual editor inside a fixed frame** | The sign-in button must not be removable; a bad edit would brick the portal for everyone at once | +| 3 | Device variables everywhere; person and quota variables on after-login pages only | Pre-login there is no signed-in user, so they would render blank | +| 4 | The splash is a **plain server-rendered Blade page**, not Inertia | The captive-network mini-browser is a stripped-down WebView; SPA bootstrapping is unreliable there | +| 5 | **Escape every substituted value; no `rawKeys`** | `{browser}` and `{device_type}` derive from a client-controlled header, on a public page | +| 6 | **No HTML sanitiser** on the operator's body | No library present, and a homemade allowlist is worse than none. Mitigated by a source-free editor and the `unifi.manage` gate. Residual risk stated below | + +--- + +## A. The four templates + +| Template key | Rendered by | Auth | Variables | +|---|---|---|---| +| `splash` | new public route, Blade | none | device only | +| `success` | `captiveCallback`, after authorizing | auth | device + person/quota | +| `over_limit` | `captiveCallback`, quota branch | auth | device + person/quota + the device list | +| `refused` | `captiveCallback`, no-mapping or resolver refusal | auth | device only — there may be no mapping to quote | + +**The splash needs a new public route.** `captiveCallback` sits behind `['web','auth']` +today, so an unauthenticated hit does `redirect()->route('login')` and the pupil +lands on the ordinary shell login. That is why there is no splash at all now. + +Flow: UniFi redirects → public splash (device facts, "Sign in with Google") → +Google → `captiveCallback` → success / over-limit / refused. + +The three after-login pages stay Inertia: by then the pupil has been through Google +in a real browser. + +### Storage + +A `unifi_portal_templates` table, one row per key: `key`, `body`, `updated_at`. +Not `Setting`, because these are long HTML bodies and a table gives a per-template +`updated_at` and a clean per-template reset to default. + +### Variables + +Device — available on all four: + +| Token | Source | Trustworthy? | +|---|---|---| +| `{ip}` | `$request->ip()` via Traefik | Yes in practice — production shows real per-client addresses | +| `{mac}` | derived from the IP via UniFi's client table | Yes — server-derived | +| `{is_randomized}` | locally-administered bit on the derived MAC | Yes — server-derived | +| `{browser}` | `App\Support\UserAgent::describe()` | **No — client-controlled** | +| `{device_type}` | same | **No — client-controlled** | +| `{ssid}`, `{ap_name}` | the redirect / the client record | Yes | + +Person and quota — after-login pages only: `{name}`, `{role}`, `{devices_used}`, +`{device_limit}`, `{expires_on}`. + +On the splash template the person and quota tokens are **shown disabled with the +reason**, not hidden and not silently rendering blank. Hiding them invites the +question "why can't I show their name"; blanking them ships a broken page. + +--- + +## B. The editor + +A new tab on the existing Unifi → Portal settings page, gated on `unifi.manage` — +the key that already guards portal settings. + +- **Template switcher** across the four keys. One screen, not four. +- **Fixed frame.** Logo slot, editable content region, sign-in button, footer. The + frame and the button are our markup and are not part of the editable body, so no + edit can remove the way in. +- **Variable picker** beside the editor, grouped Device / Person & Quota, built from + `TemplateRender`'s grouping helper. +- **Live preview** through the JS mirror, fed sample values. The token set is + defined once, server-side, and handed to the page — the memory on this engine is + explicit that the JS mirror and the PHP engine must change together, so a single + source for the token list is the point. +- **Images must be local.** The picker offers uploaded images only, and pasting an + external URL warns. See section C — a remote asset cannot load on the splash. +- **Reset to default** per template. + +--- + +## C. Rendering safety + +### Escape everything + +`TemplateRender::apply($body, $values, escapeValues: true)` with **no `rawKeys`**. +`{browser}` and `{device_type}` come from the User-Agent, so a crafted header would +otherwise be reflected XSS on an unauthenticated page. `{ip}` and `{mac}` are +server-derived and escaped anyway, because there is no reason not to. Nothing needs +to bypass escaping: with a fixed frame the sign-in button is markup, not a token. + +The engine defaults `escapeValues` to false, which is correct for its email and PDF +callers. This is therefore an explicit decision at the call site, and the reason +belongs in a comment there. + +### No sanitiser on the operator's body, and what that leaves + +There is no HTML sanitising library in the project and no allowlist helper. Writing +one is rejected: a homemade allowlist is leaky in ways nobody audits, which is worse +than not having one. + +Mitigations: the editor is visual-only and offers no source view or script +insertion, and the body is editable only by `unifi.manage` holders. + +**Residual risk, stated plainly:** an admin holding `unifi.manage` can put arbitrary +HTML on a public page. That is a trusted-insider scenario rather than an escalation — +the same admin already controls the portal, the VLAN mappings and the device quotas. +But it is a real difference from signatures, which use the same engine and render +into email rather than onto the open internet. If the trust boundary ever changes — +say portal editing is delegated to a non-admin role — this decision must be revisited +first. + +### The constraint most likely to break a rollout + +**The splash is served before the device has internet access.** That is what a +captive portal is. Any external asset — a CDN, Google Fonts, a remote logo — silently +fails to load, and the pupil gets an unstyled page at the worst possible moment. + +Therefore: all CSS and images local, no external fonts, and UniFi's pre-auth walled +garden must allow this host. The likeliest way this breaks in practice is a +well-meaning operator pasting a remote logo URL into the editor, which is why +section B restricts images rather than merely advising. + +### Fallback + +If a template row is missing, empty, or throws while rendering, render a built-in +default body. A blank or broken template must never leave a pupil with no way in — +the same reasoning as the fixed frame, applied to content instead of structure. + +### Headers + +The splash carries a device's MAC and IP, so: `Cache-Control: no-store` and +`noindex`. + +--- + +## D. Testing + +Covered here: + +- Escaping: a hostile User-Agent (`