# 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 (`