2 Commits

Author SHA1 Message Date
4f621e3973 Declare the Captive Portal page in the manifest
The page and all nine of its routes exist and nothing links to them, on dev and
equally on production at 6287054. It is not a dev-only gap.

One manifest row, no source, no routes, no migration. It goes here rather than in
a shell migration because syncNavFolder() PRUNES nav rows the manifest does not
declare — a hand-inserted row would be deleted by the next app:install, which is
pinned by a negative-control test in the shell so nobody rediscovers it.

Held: this repo is on .promote-hold for Plan 1 BYOD quotas, unrelated to this
screen. Committed, not promoted; the row appears the moment the hold lifts.

Permission is unifi.auth, which the portal route group requires — NOT
unifi.settings. A person can legitimately hold unifi.settings and be 403'd here.
2026-08-29 11:31:54 -04:00
868f5edeed Design: captive portal splash designer
Let an operator lay out what a pupil sees at the portal, with the device's own facts
as variables, instead of the four hardcoded strings there are today.

Mostly assembly rather than invention. TemplateRender already does {token}
substitution with {if}/{else} and value escaping; it already has a helper that
groups a token map into an editor sidebar; there is already a JS mirror for live
preview; and ActiveSessionController::device() already turns a User-Agent into
"Safari · iPhone" and already separates iPhone/iPad/Android from the desktop
platforms. The genuinely new parts are a public Blade route, a templates table and
the editor screen.

Four decisions worth recording because they were not obvious.

The splash cannot be an Inertia page. Every other page in this app is, but the
captive-network mini-browser is a stripped-down WebView where SPA bootstrapping is
unreliable — so the splash is plain server-rendered Blade and only the after-login
pages stay Inertia.

The frame is fixed and the sign-in button is not part of the editable body. A bad
edit would otherwise brick the portal for everybody at once.

Every substituted value is escaped, with no rawKeys. {browser} and {device_type}
derive from the User-Agent, which the pupil controls, on a page served without
authentication. The engine defaults escaping OFF, correctly for its email and PDF
callers, so this is an explicit call at the call site.

And the constraint most likely to break a rollout: the splash is served BEFORE the
device has internet, so any external asset silently fails and the pupil gets an
unstyled page at the worst moment. All assets local, walled garden must allow this
host, and the editor restricts images rather than merely advising — because a
well-meaning operator pasting a remote logo URL is the likeliest way this breaks.

No HTML sanitiser, deliberately: there is no library here and a homemade allowlist
is leaky in ways nobody audits. The residual risk is stated rather than glossed — an
admin with unifi.manage can put arbitrary HTML on a public page, which is
trusted-insider rather than escalation, but it differs from signatures because those
render into email and this renders onto the open internet. Revisit if portal editing
is ever delegated.

Queues behind the six open findings from the Plan 1 review.
2026-08-21 15:32:10 -04:00
2 changed files with 215 additions and 0 deletions

View File

@@ -29,6 +29,7 @@
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 }, { "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 }, { "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 }, { "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
{ "label": "Captive Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 } { "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
], ],
"permissions": [ "permissions": [

View File

@@ -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 (`<script>` and an `onerror=` attribute) renders
escaped in the splash body.
- The public splash route renders with **no** authentication.
- Person and quota tokens on the splash render as their disabled placeholder rather
than leaking a blank or a previous user's value.
- A missing template row, and an empty body, each fall back to the default.
- The after-login pages render quota values — `devices_used`, `device_limit`,
`expires_on`.
- **Rendered splash HTML references no external hosts.** This is the regression most
likely to creep back in, and it is invisible on a developer machine that has
internet.
- `UserAgent::describe()` against real iOS, Android, macOS and Windows strings, plus
an empty one.
Browser pass on the splash in a plain, non-Inertia context.
**Not provable here** — same hardware list as the access-model spec: whether UniFi's
walled garden lets this host through pre-auth, and how the real Captive Network
Assistant behaves with the page.
---
## Out of scope
- **Drag-and-drop or free positioning.** A captive portal is a single narrow column
on a phone; absolute positioning is actively wrong for it.
- **Conditionals on `{device_type}` as a decision.** The engine supports
`{if device_type == 'iPhone'}` and the picker will not encourage it: it puts a
spoofable value into a decision rather than a display, which contradicts the
report-only stance agreed for device type.
- **Enforcement of any kind.** This designer changes what a pupil is told, never
what they are allowed.
- **Per-role or per-SSID template variants.** One template per key. Add variants
when somebody asks for them.
- **A block builder.** Considered and rejected: it invents a pattern this codebase
has no precedent for, and the layout freedom it buys is limited on a phone.