Invite setup page (HTML+JS) #5

Closed
opened 2026-05-09 23:24:51 +02:00 by arne · 1 comment
Owner

What to build

The browser-side companion to #4. When a user clicks https://<id-url>/setup#invite=pinv_…, the daemon serves a small HTML+JS page that:

  1. Reads window.location.hash to extract the invite token
  2. Calls GET /api/v1/invite/info?invite=... — does NOT consume the invite, just validates and fetches identity name + device-hint for prefilling
  3. Renders "Pair a device with " + a device-name input (prefilled with hint) + a "Pair" button
  4. On submit, POSTs /api/v1/invite/redeem with {invite, deviceName}, gets {token, identity, deviceName}
  5. Displays mst_… plaintext once with a copy button + a posta://pair?token=…&url=… deep-link button (no-op until iOS/TUI clients handle the scheme — but the link is there now so it keeps working when clients ship)
  6. On reload after consume: 410 → render "this invite has been used" view

Visual treatment matches the landing card from commit 793e9ed (paper/sky/forest palette, Newsreader/Schibsted/IBM Plex Mono, embedded HTML+CSS, ~30 lines of vanilla JS, no framework).

The fragment-not-query design keeps the secret out of access logs — JS reads it client-side and posts it over the body of the redeem call.

Acceptance criteria

  • GET /setup serves the HTML page (content-negotiated alongside the existing landing card path)
  • Page is unauthenticated (auth middleware carve-out for /setup and /api/v1/invite/*)
  • Vanilla JS reads #invite= from window.location.hash
  • Two-call flow: info-then-redeem (no auto-redeem on page load — would burn invites on link previews)
  • Form pre-fills device-name from deviceHint
  • Success view: token plaintext + Copy button + posta://pair?... deep link
  • Already-consumed view: friendly message, no token shown
  • Graceful no-JS fallback message ("JavaScript required to complete pairing")
  • Visual style consistent with the landing card

Blocked by

## What to build The browser-side companion to #4. When a user clicks `https://<id-url>/setup#invite=pinv_…`, the daemon serves a small HTML+JS page that: 1. Reads `window.location.hash` to extract the invite token 2. Calls `GET /api/v1/invite/info?invite=...` — does NOT consume the invite, just validates and fetches identity name + device-hint for prefilling 3. Renders "Pair a device with **<identity name>**" + a device-name input (prefilled with hint) + a "Pair" button 4. On submit, POSTs `/api/v1/invite/redeem` with `{invite, deviceName}`, gets `{token, identity, deviceName}` 5. Displays `mst_…` plaintext once with a copy button + a `posta://pair?token=…&url=…` deep-link button (no-op until iOS/TUI clients handle the scheme — but the link is there now so it keeps working when clients ship) 6. On reload after consume: 410 → render "this invite has been used" view Visual treatment matches the landing card from commit `793e9ed` (paper/sky/forest palette, Newsreader/Schibsted/IBM Plex Mono, embedded HTML+CSS, ~30 lines of vanilla JS, no framework). The fragment-not-query design keeps the secret out of access logs — JS reads it client-side and posts it over the body of the redeem call. ## Acceptance criteria - [ ] `GET /setup` serves the HTML page (content-negotiated alongside the existing landing card path) - [ ] Page is unauthenticated (auth middleware carve-out for `/setup` and `/api/v1/invite/*`) - [ ] Vanilla JS reads `#invite=` from `window.location.hash` - [ ] Two-call flow: info-then-redeem (no auto-redeem on page load — would burn invites on link previews) - [ ] Form pre-fills device-name from `deviceHint` - [ ] Success view: token plaintext + Copy button + `posta://pair?...` deep link - [ ] Already-consumed view: friendly message, no token shown - [ ] Graceful no-JS fallback message ("JavaScript required to complete pairing") - [ ] Visual style consistent with the landing card ## Blocked by - #4
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Serve a small embedded HTML+JS page at GET /setup that drives the user-facing half of the invite redemption flow defined in #4.

Blocked by: #4 (the /api/v1/invite/info and /api/v1/invite/redeem endpoints must exist for the page to call).

Current behavior:
There is no setup page. New devices get bearer tokens by the operator running posta-server token create and copying the plaintext out of band. A user clicking a future invite URL would see a 404. The only existing browser-facing surface is the landing card (commit 793e9ed) served on / for Accept: text/html — it shows the actor doc as a styled page, no interaction.

Desired behavior:
When a user opens https://<identity-url>/setup#invite=pinv_…, the daemon serves a small embedded HTML+JS page that:

  1. Reads window.location.hash client-side to extract the invite token. The token never appears in a query string and therefore never lands in access logs.
  2. Calls GET /api/v1/invite/info?invite=<plaintext> to validate without consuming. On success, the response carries the identity URL/name and an optional deviceName hint.
  3. Renders a "Pair a device with " card with a device-name input (pre-filled with deviceName from the info response if present) and a "Pair" button. No auto-submit.
  4. On submit, POSTs {invite, deviceName} to /api/v1/invite/redeem. On success, displays the returned mst_… plaintext token exactly once with a copy-to-clipboard button and a posta://pair?token=…&url=… deep-link button. The deep link is a no-op until native clients implement the scheme — including it now means it lights up automatically when iOS/TUI ship.
  5. On any 410 from either endpoint, renders an "already-used" view — a friendly message with no token, no Pair button. The page does not attempt to distinguish expired-vs-consumed; #4's /info returns 410 for both without distinguishing.
  6. On /setup with no #invite= fragment, renders an "incomplete link" message asking the user to use the full URL the operator sent.
  7. Without JavaScript, renders a <noscript> fallback explaining that pairing requires JS — no progressive-enhancement fallback path exists because the whole flow depends on reading the fragment.

Visual treatment:
Match the landing card precedent at internal/inbox/{landing.go, landing.html}:

  • Paper/sky/forest palette
  • Newsreader display type, Schibsted Grotesk for body, IBM Plex Mono for the token plaintext
  • Embedded HTML+CSS via //go:embed and html/template
  • Vanilla JS, no framework, kept small (~30 lines of behaviour)
  • Mirror posta-web's overall aesthetic

Routing and middleware:

  • /setup is a per-identity route registered on the same handler tree as /api/v1/* — it lives under the multi-tenant runner per #1 and shares the same Host-based dispatch.
  • The auth middleware carve-out from #4 (which already excludes /api/v1/invite/*) extends to cover /setup. The page itself is unauthenticated; the /redeem call inside it is what mints credentials.

Key interfaces:

  • A new HTTP handler for GET /setup per identity that responds with the embedded HTML, Content-Type: text/html; charset=utf-8, and a sane Cache-Control (the page is static-from-binary, so a long cache is fine).
  • An embedded HTML asset (alongside landing.html) containing the markup, styles, and inline JS.
  • The auth middleware's path-skip predicate covers both /setup and /api/v1/invite/*.

Acceptance criteria:

  • GET /setup returns the HTML page with the right content type, in every per-identity runner. Wrong-host requests still 404 per #1.
  • The page is unauthenticated — no bearer token required. The auth middleware carve-out covers both /setup and /api/v1/invite/*.
  • The page reads #invite=... client-side from window.location.hash. The token never appears in a query parameter.
  • The flow makes two HTTP calls — info on load, redeem on submit. There is no auto-redeem on page load (preventing link-preview crawlers from burning invites).
  • When /info succeeds, the device-name input is pre-filled with deviceName from the response (or empty if null).
  • On successful /redeem, the page displays the mst_… plaintext exactly once, a copy-to-clipboard button that works in modern browsers, and a posta://pair?token=<plaintext>&url=<identity-url> deep-link button.
  • On 410 from either endpoint, the page renders an already-used view with no token and no Pair button.
  • On /setup with no #invite= fragment, the page renders an incomplete-link message.
  • A <noscript> fallback explains JS is required.
  • Visual style (palette, fonts, layout) matches the landing card precedent.
  • An end-to-end test exercises a browser-style happy path against a running test daemon: GET /setup → call /info with a valid invite → call /redeem → assert token returned and consumed_at set.

Out of scope:

  • Any client-side handling of the posta:// deep link. The button is wired but does not activate native clients yet — that lands when iOS or TUI ship URL-scheme handlers.
  • Multi-step onboarding (set display name, choose contacts, etc.). The page does one thing: pair a device.
  • Email or notification dispatch of invite URLs.
  • Localisation or RTL support.
  • A non-JS pairing fallback. Pairing requires JS by design (fragment reading).
  • Any change to the invite endpoints themselves — those are #4.
  • Token refresh or rotation flows. The minted mst_… is a normal long-lived bearer token like any other.
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Serve a small embedded HTML+JS page at `GET /setup` that drives the user-facing half of the invite redemption flow defined in #4. **Blocked by:** #4 (the `/api/v1/invite/info` and `/api/v1/invite/redeem` endpoints must exist for the page to call). **Current behavior:** There is no setup page. New devices get bearer tokens by the operator running `posta-server token create` and copying the plaintext out of band. A user clicking a future invite URL would see a 404. The only existing browser-facing surface is the landing card (commit 793e9ed) served on `/` for `Accept: text/html` — it shows the actor doc as a styled page, no interaction. **Desired behavior:** When a user opens `https://<identity-url>/setup#invite=pinv_…`, the daemon serves a small embedded HTML+JS page that: 1. Reads `window.location.hash` client-side to extract the invite token. The token never appears in a query string and therefore never lands in access logs. 2. Calls `GET /api/v1/invite/info?invite=<plaintext>` to validate without consuming. On success, the response carries the identity URL/name and an optional `deviceName` hint. 3. Renders a "Pair a device with **<identity name>**" card with a device-name input (pre-filled with `deviceName` from the info response if present) and a "Pair" button. No auto-submit. 4. On submit, POSTs `{invite, deviceName}` to `/api/v1/invite/redeem`. On success, displays the returned `mst_…` plaintext token exactly once with a copy-to-clipboard button and a `posta://pair?token=…&url=…` deep-link button. The deep link is a no-op until native clients implement the scheme — including it now means it lights up automatically when iOS/TUI ship. 5. On any 410 from either endpoint, renders an "already-used" view — a friendly message with no token, no Pair button. The page does not attempt to distinguish expired-vs-consumed; #4's `/info` returns 410 for both without distinguishing. 6. On `/setup` with no `#invite=` fragment, renders an "incomplete link" message asking the user to use the full URL the operator sent. 7. Without JavaScript, renders a `<noscript>` fallback explaining that pairing requires JS — no progressive-enhancement fallback path exists because the whole flow depends on reading the fragment. **Visual treatment:** Match the landing card precedent at `internal/inbox/{landing.go, landing.html}`: - Paper/sky/forest palette - Newsreader display type, Schibsted Grotesk for body, IBM Plex Mono for the token plaintext - Embedded HTML+CSS via `//go:embed` and `html/template` - Vanilla JS, no framework, kept small (~30 lines of behaviour) - Mirror posta-web's overall aesthetic **Routing and middleware:** - `/setup` is a per-identity route registered on the same handler tree as `/api/v1/*` — it lives under the multi-tenant runner per #1 and shares the same Host-based dispatch. - The auth middleware carve-out from #4 (which already excludes `/api/v1/invite/*`) extends to cover `/setup`. The page itself is unauthenticated; the `/redeem` call inside it is what mints credentials. **Key interfaces:** - A new HTTP handler for `GET /setup` per identity that responds with the embedded HTML, `Content-Type: text/html; charset=utf-8`, and a sane `Cache-Control` (the page is static-from-binary, so a long cache is fine). - An embedded HTML asset (alongside `landing.html`) containing the markup, styles, and inline JS. - The auth middleware's path-skip predicate covers both `/setup` and `/api/v1/invite/*`. **Acceptance criteria:** - [ ] `GET /setup` returns the HTML page with the right content type, in every per-identity runner. Wrong-host requests still 404 per #1. - [ ] The page is unauthenticated — no bearer token required. The auth middleware carve-out covers both `/setup` and `/api/v1/invite/*`. - [ ] The page reads `#invite=...` client-side from `window.location.hash`. The token never appears in a query parameter. - [ ] The flow makes two HTTP calls — `info` on load, `redeem` on submit. There is no auto-redeem on page load (preventing link-preview crawlers from burning invites). - [ ] When `/info` succeeds, the device-name input is pre-filled with `deviceName` from the response (or empty if `null`). - [ ] On successful `/redeem`, the page displays the `mst_…` plaintext exactly once, a copy-to-clipboard button that works in modern browsers, and a `posta://pair?token=<plaintext>&url=<identity-url>` deep-link button. - [ ] On 410 from either endpoint, the page renders an already-used view with no token and no Pair button. - [ ] On `/setup` with no `#invite=` fragment, the page renders an incomplete-link message. - [ ] A `<noscript>` fallback explains JS is required. - [ ] Visual style (palette, fonts, layout) matches the landing card precedent. - [ ] An end-to-end test exercises a browser-style happy path against a running test daemon: GET /setup → call /info with a valid invite → call /redeem → assert token returned and consumed_at set. **Out of scope:** - Any client-side handling of the `posta://` deep link. The button is wired but does not activate native clients yet — that lands when iOS or TUI ship URL-scheme handlers. - Multi-step onboarding (set display name, choose contacts, etc.). The page does one thing: pair a device. - Email or notification dispatch of invite URLs. - Localisation or RTL support. - A non-JS pairing fallback. Pairing requires JS by design (fragment reading). - Any change to the invite endpoints themselves — those are #4. - Token refresh or rotation flows. The minted `mst_…` is a normal long-lived bearer token like any other.
arne closed this issue 2026-05-10 01:19:49 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/server#5
No description provided.