oficina / home Public

Put Home's own pages on the shared chrome

The 52px estilo bar, branded Oficina, on every page except the marketing
front page. The directory's account menu (Signed in, Passkeys, Recovery
codes, Appearance, Help > Open source, one Sign out) replaces the lone
Help menu and Sign out button; id.* pages are estilo's card on the sky.
The sky stays on sign-in, the team home band and the hero. Errors are
estilo pages split by Accept; titles read '<Page> · Oficina'. 'Use a
different address' and 'Start again' keep the destination, the hop page
keeps its bar and sky, and Onest and the marks are preloaded (CLS 0).
Reviewed by Claude Fable 5.1 (#81): approve with nits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/r2-shared-chrome into main.
claude agent, operated by paul@keymail.dev 83c5d1692b77afd592bf811b91221529ef793eef parent c73f5f1
39 files changed, +801 −213
  • AGENTS.md +12 −0
  • internal/home/app.go +5 −0
  • internal/home/calma.go +40 −11
  • internal/home/chrome.go +191 −0
  • internal/home/continuation.go +2 −2
  • internal/home/credits.go +1 −1
  • internal/home/directory.go +12 −2
  • internal/home/directory_catalog_test.go +3 −3
  • internal/home/docs_chooser.go +13 −13
  • internal/home/docs_continue_test.go +3 −3
  • internal/home/exchange.go +12 −12
  • internal/home/exchange_test.go +1 −1
  • internal/home/handoff_failure.go +5 −5
  • internal/home/handoff_reveal_test.go +8 −5
  • internal/home/origins_test.go +2 −0
  • internal/home/passkey.go +6 −6
  • internal/home/render.go +36 −10
  • internal/home/shared_chrome_test.go +302 −0
  • internal/home/signin.go +27 −15
  • internal/home/signout.go +3 −3
  • internal/home/static/calma.css +32 −57
  • internal/home/static/site/index.html +9 −1
  • internal/home/templates/assertion.html +7 −10
  • internal/home/templates/check_your_mail.html +4 −4
  • internal/home/templates/credits.html +1 −3
  • internal/home/templates/directory.html +8 −11
  • internal/home/templates/directory_signed_out.html +2 −2
  • internal/home/templates/docs_chooser.html +3 −3
  • internal/home/templates/docs_continue.html +4 −4
  • internal/home/templates/docs_empty.html +1 −1
  • internal/home/templates/error.html +2 −0
  • internal/home/templates/handoff_failed.html +2 −2
  • internal/home/templates/layout.html +31 −12
  • internal/home/templates/passkey_confirm.html +2 −2
  • internal/home/templates/passkey_enrol.html +1 −1
  • internal/home/templates/recovery_codes.html +3 −3
  • internal/home/templates/signin.html +2 −2
  • internal/home/templates/signin_confirm.html +1 −1
  • internal/home/templates/signin_failed.html +2 −2

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go vet -tags browser ./internal/home/
cd handoff && CGO_ENABLED=0 go vet ./...
cd roster && CGO_ENABLED=0 go vet ./...
cd connect && CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch
cd handoff && CGO_ENABLED=0 go build ./...
cd roster && CGO_ENABLED=0 go build ./...
cd connect && CGO_ENABLED=0 go build ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/home/cmd/home	[no test files]
ok  	amadan.net/oficina/home/cmd/home-placeholder	0.554s
?   	amadan.net/oficina/home/cmd/oficina	[no test files]
ok  	amadan.net/oficina/home/internal/guardstest	0.034s
ok  	amadan.net/oficina/home/internal/home	10.527s
ok  	amadan.net/oficina/home/internal/home/directorydata	0.937s
ok  	amadan.net/oficina/home/internal/home/logtest	0.005s
ok  	amadan.net/oficina/home/internal/home/seal	0.003s
ok  	amadan.net/oficina/home/internal/home/sealtest	0.004s
ok  	amadan.net/oficina/home/internal/operator	4.984s
ok  	amadan.net/oficina/home/internal/operatorlaunch	4.132s
ok  	amadan.net/oficina/home/internal/provisioner	36.595s
cd handoff && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/handoff	0.302s
ok  	amadan.net/oficina/home/handoff/seal	0.002s
cd roster && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/roster	5.074s
cd connect && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/connect	0.054s
ok  	amadan.net/oficina/home/connect/linkstore	0.168s
=== race ===
go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/
ok  	amadan.net/oficina/home/internal/home	106.888s
ok  	amadan.net/oficina/home/internal/operator	64.035s
ok  	amadan.net/oficina/home/internal/operatorlaunch	22.324s
ok  	amadan.net/oficina/home/internal/provisioner	268.368s
ok  	amadan.net/oficina/home/internal/home/directorydata	14.916s
cd handoff && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/handoff	9.118s
ok  	amadan.net/oficina/home/handoff/seal	1.009s
cd roster && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/roster	6.583s
cd connect && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/connect	1.793s
ok  	amadan.net/oficina/home/connect/linkstore	4.030s
diff --git a/AGENTS.md b/AGENTS.md
index 94d0e57..14aeabb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -334,6 +334,18 @@ quietly ignored).
Scripts are **static same-origin files, never inline `<script>`**, and
every one Home serves is named in `noscript_test.go`'s closed list —
a rule with a list is checkable, a rule with an escape hatch is not.
+- **The shared chrome** (estilo spec shared-chrome, §12.1 row 8;
+ `internal/home/chrome.go`). Every page Home draws for a person sits under
+ estilo's 52px bar, branded Oficina — except the public front page,
+ which keeps its own design. On the directory a signed-in person has one
+ account menu (Passkeys and Recovery codes on id.*, Appearance, Help →
+ Open source, the one Sign out); id.*'s pages are the door, with a
+ brand-only bar. Unknown routes get estilo's error page for a browser
+ (`Accept: text/html`) and plain text otherwise. Calma's sky goes only on
+ the door, the team home's header and the front page's hero. Home does
+ not load estilo's `chrome.js`: its menus work without it, and the
+ script rule above prefers none — the stated cost is that an open menu
+ closes from its own trigger, not on Escape or a click outside.
**Deploys**
- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
diff --git a/internal/home/app.go b/internal/home/app.go
index 3d8c01f..acf81fb 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -153,6 +153,11 @@ func (a *App) Router() http.Handler {
r := chi.NewRouter()
r.Use(a.serveKnownHost)
+ // estilo's error page for a browser, plain text for anything else
+ // (chrome.go). Unknown hosts never reach these: serveKnownHost answers
+ // them first, with an empty 404.
+ r.NotFound(a.notFound)
+ r.MethodNotAllowed(a.methodNotAllowed)
a.mountCredits(r)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/home/calma.go b/internal/home/calma.go
index 92557f3..8ea1a3c 100644
--- a/internal/home/calma.go
+++ b/internal/home/calma.go
@@ -31,11 +31,39 @@ var homeFuncs = map[string]any{
// drift from theirs. Home draws its own templates, so it reads the
// strings from estilo's types rather than rendering estilo-signin.
"signinDoor": func() chrome.SignIn { return chrome.SignIn{App: suiteName} },
- "sentDoor": func() chrome.SignInNotice { return chrome.SignInNotice{App: suiteName}.Sent("/signin") },
+ "sentDoor": func(again string) chrome.SignInNotice {
+ return chrome.SignInNotice{App: suiteName}.Sent(again)
+ },
"signedOutDoor": func() chrome.SignInNotice {
return chrome.SignInNotice{App: suiteName}.SignedOut("/auth/start")
},
"linkLifetime": func() int { return int(chrome.LinkLifetime.Minutes()) },
+ // doorView is estilo-door-open's data: the suite's sky behind one
+ // card, the Oficina mark and name at its head.
+ "doorView": func() doorView {
+ return doorView{Sky: sky.For(sky.SuiteSeed), Icon: versioned("/static/brand/icon.svg"), App: suiteName}
+ },
+ // hopSky is the sky a hop page keeps: the inline placeholder alone, a
+ // data: URI, so the moment between two skies is not a blank page and
+ // still fetches nothing it will not show (r3 C2).
+ "hopSky": func() sky.View {
+ var v sky.View
+ for _, l := range sky.For(sky.SuiteSeed).Layers {
+ if l.Inline != "" {
+ v.Layers = append(v.Layers, l)
+ }
+ }
+ return v
+ },
+ // Onest's file, preloaded so a cold first load paints in it rather
+ // than swapping in after (r3 C4).
+ "fontPreload": func() string { return theme.FontPrefix + "onest-latin.woff2" },
+}
+
+// doorView is estilo-door-open's data (chrome/templates/signin.html).
+type doorView struct {
+ Sky sky.View
+ Icon, App string
}
// suiteName is what the hub calls itself to people: the suite, never
@@ -43,11 +71,12 @@ var homeFuncs = map[string]any{
const suiteName = "Oficina"
// doorPages are the pages at the door — signing in, and everything on the
-// way in or out. The layout draws them as one card on the sky with the
-// Oficina mark, as Calma's sign-in does; every other page keeps the full
-// width (the directory lists apps; credits is a long page of notices).
-// Not assertion.html or docs_continue.html: they are hops, on screen for
-// a moment, so they draw neither the card nor the sky (hopPages).
+// way in or out, and id.*'s account pages. The layout draws them as
+// estilo's door: one card on the sky with the Oficina mark, under the bar.
+// The sky goes only where Calma puts it — the door, the team home's header
+// and the front page's hero — so the team chooser, Open source and the
+// error pages are plain pages under the bar.
+// Not assertion.html or docs_continue.html: they are hops (hopPages).
var doorPages = map[string]bool{
"signin.html": true,
"check_your_mail.html": true,
@@ -59,13 +88,12 @@ var doorPages = map[string]bool{
"handoff_failed.html": true,
"signout_confirm.html": true,
"directory_signed_out.html": true,
- "docs_chooser.html": true,
- "docs_empty.html": true,
- "docs_unavailable.html": true,
}
-// hopPages move on by themselves (an auto-submit, a 0 s refresh): no sky,
-// so the slowest-feeling moment of sign-in fetches nothing it won't show.
+// hopPages move on by themselves (an auto-submit, a 0 s refresh). They
+// keep the bar and the sky's inline placeholder, so the moment between the
+// door and the directory is not a blank page (r3 C2), but load no script,
+// no sky layer and no card: the card shows only if the hop stalls.
var hopPages = map[string]bool{
"assertion.html": true,
"docs_continue.html": true,
@@ -78,6 +106,7 @@ func mountCalma(r chi.Router) {
r.Handle(theme.FontPrefix+"*", theme.FontHandler())
r.Handle(sky.Prefix+"*", sky.Handler())
mountCSS(r, "/static/estilo/sky.css", sky.CSS())
+ mountChrome(r)
mountCSS(r, "/static/calma.css", calmaCSS)
// estilo's busy rule: a submit button's label gives way to a spinner
// for at least 650ms, the button disables, the form refuses a second
diff --git a/internal/home/chrome.go b/internal/home/chrome.go
new file mode 100644
index 0000000..beb34b0
--- /dev/null
+++ b/internal/home/chrome.go
@@ -0,0 +1,191 @@
+package home
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+)
+
+// The shared chrome (estilo spec shared-chrome, §12.1 row 8). Every page
+// Home draws for a person, other than the public front page, sits under
+// estilo's 52px bar with the brand "Oficina":
+//
+// - on the directory, a signed-in person gets the account menu: who is
+// signed in, Passkeys and Recovery codes (pages on id.*), Appearance,
+// Help → Open source, and the one Sign out. Signed out, the corner is
+// "Sign in", except on the signed-out page itself.
+// - on id.*, the bar is the brand alone, linking back to the directory:
+// every page there is the door, and the credential origin renders
+// nothing account-shaped (AGENTS.md, Identity).
+//
+// No chrome.js: Home prefers no script (AGENTS.md), and every menu works
+// without it. The stated cost is estilo's: with scripts off, an open menu
+// closes when its own trigger is pressed again, not on Escape or a click
+// outside.
+
+// page is what every template is executed with: the handler's own data,
+// and the request's chrome.
+type page struct {
+ Data any
+ Chrome chrome.Bar
+ // Theme is <html data-theme>: "light", "dark", or empty to follow the
+ // system (the Appearance preference, per host).
+ Theme string
+}
+
+// The account menu's and the bar's words. Home speaks English only today;
+// estilo's templates carry none of their own.
+var (
+ barLabels = chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}
+ accountLabels = chrome.AccountLabels{SignedInAs: "Signed in", Appearance: "Appearance", Help: "Help"}
+)
+
+// onCredential reports whether r arrived for id.* rather than the
+// directory.
+func (a *App) onCredential(r *http.Request) bool {
+ return r != nil && strings.ToLower(r.Host) == a.credentialHost
+}
+
+// pageFor wraps a handler's data in the request's chrome. door marks the
+// sign-in, sent and signed-out pages, whose corner stays empty. hop marks
+// a page that moves on by itself: its bar is the brand alone, as plain
+// text, so the page still holds exactly one link, the way on.
+func (a *App) pageFor(r *http.Request, data any, door, hop bool) page {
+ p := page{Data: data}
+ brand := chrome.App{Name: suiteName, Slug: "oficina", Home: "/", Icon: versioned("/static/brand/icon.svg")}
+ if hop {
+ brand.Home = ""
+ p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
+ return p
+ }
+ if a.onCredential(r) || r == nil {
+ brand.Home = a.origins.Directory + "/"
+ p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
+ return p
+ }
+ p.Theme = chrome.ThemeAttr(chrome.Appearance(r))
+ p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
+ if _, ok := sessions.Current(r); ok {
+ p.Chrome.Account = a.account(r)
+ } else if !door {
+ p.Chrome.SignIn = &chrome.Link{Href: signInHref(r), Label: "Sign in"}
+ }
+ return p
+}
+
+// account is the directory's account menu. The directory cannot read the
+// address (it holds a person ref, never an address: the one rule), so the
+// identity row says only "Signed in" and the trigger is the person glyph.
+func (a *App) account(r *http.Request) *chrome.Account {
+ return &chrome.Account{
+ TriggerLabel: "Account",
+ Labels: accountLabels,
+ Items: []menu.Item{
+ {Href: a.origins.Credential + "/passkey/enrol", Label: "Passkeys"},
+ {Href: a.origins.Credential + "/passkey/codes", Label: "Recovery codes"},
+ },
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ // The path alone: no directory page reads its query, and a
+ // query is where a crafted link would carry its text.
+ Return: r.URL.EscapedPath(),
+ Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
+ },
+ Help: []menu.Item{{Href: "/help/open-source", Label: "Open source"}},
+ SignOut: chrome.SignOut{Action: signoutPath, Label: "Sign out"},
+ }
+}
+
+// signInHref starts the directory's sign-in and comes back to this page.
+// An error page comes back to the directory instead: its own URL is the
+// one thing known not to exist.
+func signInHref(r *http.Request) string {
+ back := "/"
+ if r.Method == http.MethodGet && r.URL.Path == "/help/open-source" {
+ back = r.URL.Path
+ }
+ return handoff.StartPath + "?return_to=" + url.QueryEscape(back)
+}
+
+// mountChrome serves estilo's base, menu and chrome stylesheets, on both
+// origins.
+func mountChrome(r chi.Router) {
+ mountCSS(r, "/static/estilo/base.css", base.CSS())
+ mountCSS(r, "/static/estilo/menu.css", menu.CSS())
+ mountCSS(r, "/static/estilo/chrome.css", chrome.CSS())
+}
+
+// wantsHTML reports whether the request is a browser's page load: its
+// Accept names text/html. Everything else (a fetch, curl, a monitor) gets
+// plain text, as it always has (spec §3.3).
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// errorCopy is estilo spec §3.2's copy for the statuses Home answers.
+var errorCopy = map[int]struct{ page, heading, body string }{
+ http.StatusNotFound: {"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."},
+ http.StatusInternalServerError: {"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."},
+}
+
+// errorPage answers status as estilo's error page, under the bar, for a
+// browser, and as plain text for anything else. A 500 carries a reference
+// to quote, which is also logged, so the two can be matched.
+func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int) {
+ w.Header().Set("Cache-Control", "no-store")
+ if !wantsHTML(r) {
+ http.Error(w, strings.ToLower(http.StatusText(status)), status)
+ return
+ }
+ c := errorCopy[status]
+ home := chrome.Link{Href: "/", Label: "Go to Oficina"}
+ if a.onCredential(r) {
+ home.Href = a.origins.Directory + "/"
+ }
+ data := chrome.ErrorPage{Status: status, Title: c.heading, Body: c.body, Home: home}
+ if status >= 500 {
+ data.Ref, data.RefLabel = rastrillo.NewRef(), "Reference:"
+ a.logger.Error("error page", "ref", data.Ref, "path", r.URL.Path)
+ if r.Method == http.MethodGet {
+ data.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
+ }
+ }
+ a.renderPage(w, r, status, "error.html", errorView{chrome.Title(c.page, suiteName), data})
+}
+
+// errorView is error.html's data: the tab title and estilo-error's.
+type errorView struct {
+ Title string
+ Error chrome.ErrorPage
+}
+
+// notFound is the 404 for an unknown route, on either origin.
+func (a *App) notFound(w http.ResponseWriter, r *http.Request) {
+ a.errorPage(w, r, http.StatusNotFound)
+}
+
+// methodNotAllowed: a browser's GET or HEAD for a path that only takes a
+// POST is, to the person who typed it, a page that is not there; anything
+// else is told 405 in plain text, as before.
+func (a *App) methodNotAllowed(w http.ResponseWriter, r *http.Request) {
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && wantsHTML(r) {
+ a.notFound(w, r)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+}
+
+// serverError is the 500 for a handler that failed after deciding to
+// answer: the error page with a reference.
+func (a *App) serverError(w http.ResponseWriter, r *http.Request) {
+ a.errorPage(w, r, http.StatusInternalServerError)
+}
diff --git a/internal/home/continuation.go b/internal/home/continuation.go
index 358ac78..80c63d3 100644
--- a/internal/home/continuation.go
+++ b/internal/home/continuation.go
@@ -4,8 +4,8 @@ import "net/http"
// renderContinuation ends the submitted form response before external navigation.
// Callers supply a validated app destination or the sign-in flow's provider URL.
-func (a *App) renderContinuation(w http.ResponseWriter, destination, title string) {
+func (a *App) renderContinuation(w http.ResponseWriter, r *http.Request, destination, title string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
- a.render(w, http.StatusOK, "docs_continue.html", struct{ Destination, Title string }{destination, title})
+ a.render(w, r, http.StatusOK, "docs_continue.html", struct{ Destination, Title string }{destination, title})
}
diff --git a/internal/home/credits.go b/internal/home/credits.go
index 07dded1..6ab24aa 100644
--- a/internal/home/credits.go
+++ b/internal/home/credits.go
@@ -28,7 +28,7 @@ var embeddedCredits = func() creditInventory {
func (a *App) mountCredits(r chi.Router) {
r.Get("/help/open-source", func(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components})
+ a.render(w, r, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components})
})
mountCSS(r, "/static/credits.css", credits.CSS())
}
diff --git a/internal/home/directory.go b/internal/home/directory.go
index fad60da..3c06ed7 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/theme"
"amadan.net/oficina/home/handoff"
"amadan.net/rastrillo/rastrillo/assertion"
@@ -65,6 +66,10 @@ func (a *App) directoryRouter() http.Handler {
r.Use(a.serveKnownHost)
r.Use(a.chooserCookies)
r.Use(a.sessions.Middleware)
+ // After the session middleware, so a signed-in person's 404 shows their
+ // account menu (spec §3.3).
+ r.NotFound(a.notFound)
+ r.MethodNotAllowed(a.methodNotAllowed)
a.mountCredits(r)
// This callback alone accepts a cross-origin POST, with its own exact issuer
// check and browser-bound finish. Every other mutation retains CSRF protection.
@@ -103,8 +108,13 @@ func (a *App) directoryRouter() http.Handler {
a.handleIndex(w, r)
return
}
- a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)})
+ a.render(w, r, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)})
})
+ // The account menu's Appearance (spec §8.3): inside Protect, outside
+ // any session requirement, so a session that expired mid-page does
+ // not turn the click into a sign-in loop. A host-only cookie, so the
+ // choice is the directory's own.
+ r.Post("/appearance", chrome.HandleAppearance)
// Sign out ends both sessions: this one here, then the id.*
// session by handing the POST on. See signoutPath.
r.Post(signoutPath, func(w http.ResponseWriter, r *http.Request) {
@@ -114,7 +124,7 @@ func (a *App) directoryRouter() http.Handler {
})
r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
- a.render(w, 200, "directory_signed_out.html", map[string]any{})
+ a.render(w, r, 200, "directory_signed_out.html", map[string]any{})
})
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
mountCSS(r, "/static/tokens.css", staticAsset("tokens.css"))
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index 58db5be..7c53711 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -50,7 +50,7 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes
t.Fatal("missing app", name)
}
}
- if strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || hasOtherScript(body) {
+ if strings.Count(body, "<a home-app ") != 6 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || hasOtherScript(body) {
t.Fatal("catalog advertised unsupported launch", body)
}
if strings.Count(body, "Not connected to Oficina yet") != 0 || strings.Count(body, "Coming soon") != 0 || strings.Count(body, "Separate sign-in") != 2 {
@@ -74,7 +74,7 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) {
cookie := f.session(t, chooserPerson)
page := f.request("GET", "/?return_to=https://evil.example", "", "", cookie)
body := page.Body.String()
- if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Count(body, "Choose a team") != 5 || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 {
+ if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Count(body, "Choose a team") != 5 || strings.Contains(body, "evil.example") || strings.Count(body, "<a home-app ") != 6 || strings.Count(body, `href="/help/open-source"`) != 1 {
t.Fatal("preview navigation incorrect", page.Code, body)
}
noStore(t, page)
@@ -171,7 +171,7 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) {
f.handler = app.Router()
page := f.request("GET", "/?app=slides&origin=https://attacker.example", "", "", f.session(t, chooserPerson))
body := page.Body.String()
- if page.Code != 200 || strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
+ if page.Code != 200 || strings.Count(body, "<a home-app ") != 6 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
t.Fatal(page.Code, body)
}
noStore(t, page)
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go
index 828baa7..d71c1bb 100644
--- a/internal/home/docs_chooser.go
+++ b/internal/home/docs_chooser.go
@@ -80,16 +80,16 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
}
choices, err := c.store.Choices(r.Context(), subject, c.appID)
if err != nil {
- c.unavailable(w)
+ c.unavailable(w, r)
return
}
switch len(choices) {
case 0:
- c.app.render(w, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName})
+ c.app.render(w, r, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName})
case 1:
c.redirect(w, r, choices[0])
default:
- c.app.render(w, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath})
+ c.app.render(w, r, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath})
}
}
func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) {
@@ -127,12 +127,12 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) {
}
choices, err := c.store.Choices(r.Context(), subject, c.appID)
if err != nil {
- c.unavailable(w)
+ c.unavailable(w, r)
return
}
for _, choice := range choices {
if choice.SQID == form.Get("team") {
- c.continueToDocs(w, choice)
+ c.continueToDocs(w, r, choice)
return
}
}
@@ -145,7 +145,7 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b
if r.Method == http.MethodPost {
// Start a new GET navigation after the form response. The submitted
// selection is discarded; sign-in returns to current team choices.
- c.renderContinuation(w, c.app.origins.Directory+target)
+ c.renderContinuation(w, r, c.app.origins.Directory+target)
} else {
http.Redirect(w, r, target, http.StatusSeeOther)
}
@@ -157,8 +157,8 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b
}
return session.Subject, true
}
-func (c *docsChooser) unavailable(w http.ResponseWriter) {
- c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
+func (c *docsChooser) unavailable(w http.ResponseWriter, r *http.Request) {
+ c.app.render(w, r, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
}
// previewOrigin is the operator's preview deployment for this app, or "".
@@ -181,16 +181,16 @@ func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice di
http.Redirect(w, r, choice.Origin+"/", http.StatusSeeOther)
}
-func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) {
+func (c *docsChooser) continueToDocs(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) {
if !directorydata.ValidOrigin(choice.Origin) {
- c.unavailable(w)
+ c.unavailable(w, r)
return
}
- c.renderContinuation(w, choice.Origin+"/")
+ c.renderContinuation(w, r, choice.Origin+"/")
}
// Callers supply either a validated stored app origin plus the app root,
// or the validated directory origin plus the fixed sign-in continuation path.
-func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) {
- c.app.renderContinuation(w, destination, "Continue to "+c.appName)
+func (c *docsChooser) renderContinuation(w http.ResponseWriter, r *http.Request, destination string) {
+ c.app.renderContinuation(w, r, destination, "Continue to "+c.appName)
}
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go
index e8f5589..947deb7 100644
--- a/internal/home/docs_continue_test.go
+++ b/internal/home/docs_continue_test.go
@@ -73,12 +73,12 @@ func TestDocsContinueCanonicalResponseBoundary(t *testing.T) {
}
for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} {
r := httptest.NewRecorder()
- c.continueToDocs(r, directorydata.Choice{Origin: origin})
+ c.continueToDocs(r, httptest.NewRequest("POST", directoryOrigin+docsChoicePath, nil), directorydata.Choice{Origin: origin})
assertDocsContinuation(t, r, origin+"/")
}
for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} {
r := httptest.NewRecorder()
- c.continueToDocs(r, directorydata.Choice{Origin: origin})
+ c.continueToDocs(r, httptest.NewRequest("POST", directoryOrigin+docsChoicePath, nil), directorydata.Choice{Origin: origin})
if r.Code != 503 || strings.Contains(r.Body.String(), "http-equiv") || r.Header().Get("Location") != "" {
t.Fatal(origin, r.Code, r.Body.String())
}
@@ -89,7 +89,7 @@ func TestDocsContinueRendererEscapesIdenticalTargets(t *testing.T) {
// Exercise real template contexts independently of the fixed runtime URL.
want := "https://alpha.docs.oficina.works/auth/start?return_to=%2F&probe=%22%3C"
r := httptest.NewRecorder()
- f.p.directory.render(r, 200, "docs_continue.html", struct{ Destination, Title string }{want, "Continue to Docs"})
+ f.p.directory.render(r, httptest.NewRequest("GET", directoryOrigin+"/", nil), 200, "docs_continue.html", struct{ Destination, Title string }{want, "Continue to Docs"})
refresh, links := continuationTargets(t, r.Body.String())
if len(refresh) != 1 || refresh[0] != "0;url="+want || len(links) != 1 || links[0] != want || !strings.Contains(r.Body.String(), "&amp;probe=") {
t.Fatal(refresh, links, r.Body.String())
diff --git a/internal/home/exchange.go b/internal/home/exchange.go
index 8de9f78..bf87bb1 100644
--- a/internal/home/exchange.go
+++ b/internal/home/exchange.go
@@ -28,7 +28,7 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
maxAge := 0
if values, present := q["max_age"]; present {
if len(values) != 1 || values[0] != "300" {
- a.failAssertion(w, authorizeAge)
+ a.failAssertion(w, r, authorizeAge)
return
}
maxAge = 300
@@ -38,7 +38,7 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
wantFields++
}
if err != nil || len(q) != wantFields || len(q["consumer"]) != 1 || len(q["nonce"]) != 1 || !handoff.ValidNonce(q.Get("nonce")) || a.runtime.consumers[q.Get("consumer")] == "" {
- a.failAssertion(w, authorizeRequest)
+ a.failAssertion(w, r, authorizeRequest)
return
}
intent := assertionIntent{Consumer: q.Get("consumer"), Nonce: q.Get("nonce"), Expires: time.Now().Unix() + 600, MaxAge: maxAge}
@@ -49,12 +49,12 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
}
b, err := json.Marshal(intent)
if err != nil {
- a.failAssertion(w, authorizeEncode)
+ a.failAssertion(w, r, authorizeEncode)
return
}
sealed, err := a.secrets.Sealer.Seal("assertion.intent", b)
if err != nil {
- a.failAssertion(w, authorizeSeal)
+ a.failAssertion(w, r, authorizeSeal)
return
}
http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: base64.RawURLEncoding.EncodeToString(sealed), Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
@@ -65,23 +65,23 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
// no-referrer would turn the cross-origin form POST's Origin into null.
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if r.URL.RawQuery != "" {
- a.failAssertion(w, assertionQuery)
+ a.failAssertion(w, r, assertionQuery)
return
}
now := time.Now()
intent, ok := a.readAssertionIntent(r, now)
if !ok {
- a.failAssertion(w, assertionIntentInvalid)
+ a.failAssertion(w, r, assertionIntentInvalid)
return
}
target, ok := a.runtime.consumers[intent.Consumer]
if !ok {
- a.failAssertion(w, assertionConsumer)
+ a.failAssertion(w, r, assertionConsumer)
return
}
session, ok := sessions.Current(r)
if !ok {
- a.failAssertion(w, assertionSession)
+ a.failAssertion(w, r, assertionSession)
return
}
authTime := session.AuthTime
@@ -89,7 +89,7 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
authTime = session.At
}
if intent.Subject != "" && intent.Subject != session.Subject {
- a.failAssertion(w, assertionSubject)
+ a.failAssertion(w, r, assertionSubject)
return
}
if intent.MaxAge != 0 && (!sessions.Fresh(session, time.Duration(intent.MaxAge)*time.Second, now) || authTime.After(now)) {
@@ -98,14 +98,14 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
}
id, err := assertion.RandomID()
if err != nil {
- a.failAssertion(w, assertionRandom)
+ a.failAssertion(w, r, assertionRandom)
return
}
token, err := assertion.Sign(a.runtime.signer, assertion.Claims{Version: 1, Issuer: a.origins.Credential, Audience: target, Subject: session.Subject, KeyID: a.runtime.keyID, ID: id, Nonce: intent.Nonce, IssuedAt: now.Unix(), ExpiresAt: now.Unix() + 120, AuthTime: authTime.Unix()})
if err != nil {
- a.failAssertion(w, assertionSign)
+ a.failAssertion(w, r, assertionSign)
return
}
http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
- a.render(w, http.StatusOK, "assertion.html", map[string]any{"Action": target + handoff.CallbackPath, "Token": token})
+ a.render(w, r, http.StatusOK, "assertion.html", map[string]any{"Action": target + handoff.CallbackPath, "Token": token})
}
diff --git a/internal/home/exchange_test.go b/internal/home/exchange_test.go
index 2cfe33f..46119f8 100644
--- a/internal/home/exchange_test.go
+++ b/internal/home/exchange_test.go
@@ -154,7 +154,7 @@ func TestHomeDirectoryExchangeAndPrivacy(t *testing.T) {
token, _ := p.mint(t, intent, credentialSession)
directorySession := p.finish(t, token, pending)
page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directorySession)
- if page.Code != 200 || !strings.Contains(page.Body.String(), "You are signed in.") {
+ if page.Code != 200 || !strings.Contains(page.Body.String(), "<details estilo-account") {
t.Fatal("directory session missing")
}
var subject, authTime string
diff --git a/internal/home/handoff_failure.go b/internal/home/handoff_failure.go
index 555eea6..1aa45da 100644
--- a/internal/home/handoff_failure.go
+++ b/internal/home/handoff_failure.go
@@ -23,7 +23,7 @@ const (
)
// Only this fixed vocabulary reaches logs; no request or error is accepted.
-func (a *App) failAssertion(w http.ResponseWriter, code handoffFailureCode) {
+func (a *App) failAssertion(w http.ResponseWriter, r *http.Request, code handoffFailureCode) {
stage, reason := "assertion", "unknown"
switch code {
case authorizeAge:
@@ -50,7 +50,7 @@ func (a *App) failAssertion(w http.ResponseWriter, code handoffFailureCode) {
reason = "signing"
}
a.logger.Warn("home handoff failed", "stage", stage, "reason", reason)
- a.renderHandoffFailure(w, http.StatusBadRequest)
+ a.renderHandoffFailure(w, r, http.StatusBadRequest)
}
func (a *App) failDirectoryHandoff(w http.ResponseWriter, r *http.Request, status int) {
@@ -68,10 +68,10 @@ func (a *App) failDirectoryHandoff(w http.ResponseWriter, r *http.Request, statu
reason = "unavailable"
}
a.logger.Warn("home handoff failed", "stage", stage, "reason", reason)
- a.renderHandoffFailure(w, status)
+ a.renderHandoffFailure(w, r, status)
}
-func (a *App) renderHandoffFailure(w http.ResponseWriter, status int) {
+func (a *App) renderHandoffFailure(w http.ResponseWriter, r *http.Request, status int) {
w.Header().Set("Cache-Control", "no-store")
- a.render(w, status, "handoff_failed.html", map[string]any{"HomeURL": a.origins.Directory})
+ a.render(w, r, status, "handoff_failed.html", map[string]any{"HomeURL": a.origins.Directory})
}
diff --git a/internal/home/handoff_reveal_test.go b/internal/home/handoff_reveal_test.go
index 28bf21c..3577e79 100644
--- a/internal/home/handoff_reveal_test.go
+++ b/internal/home/handoff_reveal_test.go
@@ -16,7 +16,7 @@ import (
func TestHandoffInterstitialStaysHiddenWhileItAutoSubmits(t *testing.T) {
app := newTestApp(t)
var buf bytes.Buffer
- if err := app.renderer.pages["assertion.html"].ExecuteTemplate(&buf, "layout", map[string]any{"Action": "https://blue.docs.oficina.works/auth/assertion", "Token": "sample-token"}); err != nil {
+ if err := app.renderer.pages["assertion.html"].ExecuteTemplate(&buf, "layout", page{Data: map[string]any{"Action": "https://blue.docs.oficina.works/auth/assertion", "Token": "sample-token"}}); err != nil {
t.Fatal(err)
}
page := buf.String()
@@ -35,12 +35,15 @@ func TestHandoffInterstitialStaysHiddenWhileItAutoSubmits(t *testing.T) {
}
}
// Everything the person could read or press is inside the hidden
- // wrapper, and the form the script submits is still the one there.
+ // card, and the form the script submits is still the one there. The
+ // bar and the sky's placeholder sit outside it, so the hop is never a
+ // blank page (r3 C2).
main := body[strings.Index(body, "<main"):strings.Index(body, "</main>")]
- inner := strings.TrimSpace(main[strings.Index(main, ">")+1:])
- if !strings.HasPrefix(inner, "<div estilo-handoff>") || !strings.HasSuffix(inner, "</div>") {
- t.Fatalf("content not wrapped:\n%s", main)
+ start := strings.Index(main, "<section estilo-signin estilo-handoff>")
+ if start < 0 || !strings.Contains(main[:start], `estilo-sky-layer="placeholder"`) {
+ t.Fatalf("content not wrapped in the hidden card on the sky:\n%s", main)
}
+ inner := main[start:strings.Index(main, "</section>")]
if !strings.Contains(inner, `data-assertion-handoff`) || !strings.Contains(inner, `<button type="submit" rst-btn="primary">Continue</button>`) {
t.Fatalf("hand-off form changed:\n%s", inner)
}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
index 7563a87..c5631bf 100644
--- a/internal/home/origins_test.go
+++ b/internal/home/origins_test.go
@@ -23,6 +23,8 @@ var sharedPublicPaths = []string{
"/static/estilo/fonts/v1/*", "/static/estilo/sky/v1/*", "/static/estilo/sky.css", "/static/calma.css",
"/static/busy.css", "/static/busy.js",
"/static/site/home.css", "/static/site/apps.css",
+ // estilo's shared chrome: base links, menus, the bar and the door.
+ "/static/estilo/base.css", "/static/estilo/menu.css", "/static/estilo/chrome.css",
}
// examplePath is a real file under a wildcard route, so fetching it
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index 1a026ad..87a084c 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -135,7 +135,7 @@ func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]a
// The recovery form is a plain POST and works with script off, which is
// the point — recovery is exactly the moment WebAuthn is not working.
func (a *App) handlePasskeyConfirm(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "passkey_confirm.html", a.passkeyPageData("", map[string]any{
+ a.render(w, r, http.StatusOK, "passkey_confirm.html", a.passkeyPageData("", map[string]any{
"RecoveryFailed": r.URL.Query().Get("recovery") == "failed",
}))
}
@@ -148,7 +148,7 @@ func (a *App) handlePasskeyEnrol(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
- a.render(w, http.StatusOK, "passkey_enrol.html", a.passkeyPageData(sess.Subject, nil))
+ a.render(w, r, http.StatusOK, "passkey_enrol.html", a.passkeyPageData(sess.Subject, nil))
}
// handleRecoveryCodesPage shows how many codes are left, and the form
@@ -168,10 +168,10 @@ func (a *App) handleRecoveryCodesPage(w http.ResponseWriter, r *http.Request) {
remaining, err := a.passkeys.RecoveryCodesRemaining(sess.Subject)
if err != nil {
a.logger.Error("count recovery codes", "person", sess.Subject, "err", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ a.serverError(w, r)
return
}
- a.render(w, http.StatusOK, "recovery_codes.html", map[string]any{"Remaining": remaining})
+ a.render(w, r, http.StatusOK, "recovery_codes.html", map[string]any{"Remaining": remaining})
}
// handleRegenerateRecoveryCodes mints a fresh set and shows it once.
@@ -184,10 +184,10 @@ func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Reque
codes, err := a.passkeys.RegenerateRecoveryCodes(sess.Subject)
if err != nil {
a.logger.Error("regenerate recovery codes", "person", sess.Subject, "err", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ a.serverError(w, r)
return
}
- a.render(w, http.StatusOK, "recovery_codes.html", map[string]any{"Codes": codes})
+ a.render(w, r, http.StatusOK, "recovery_codes.html", map[string]any{"Codes": codes})
}
// serveAsset serves one of the two script files.
diff --git a/internal/home/render.go b/internal/home/render.go
index 0a6fac2..f63ed7f 100644
--- a/internal/home/render.go
+++ b/internal/home/render.go
@@ -9,7 +9,9 @@ import (
"net/http"
"path"
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/credits"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/rastrillo/rastrillo/ui"
)
@@ -67,6 +69,14 @@ func newRenderer() (*renderer, error) {
if t, err = t.ParseFS(sky.Templates(), "templates/*.html"); err != nil {
return nil, fmt.Errorf("parse sky partials: %w", err)
}
+ // The shared chrome: the bar, the account menu, the door and the
+ // error page (chrome.go).
+ if t, err = t.ParseFS(menu.Templates(), "templates/*.html"); err != nil {
+ return nil, fmt.Errorf("parse menu partials: %w", err)
+ }
+ if t, err = t.ParseFS(chrome.Templates(), "templates/*.html"); err != nil {
+ return nil, fmt.Errorf("parse chrome partials: %w", err)
+ }
if t, err = t.ParseFS(templatesFS, f); err != nil {
return nil, fmt.Errorf("parse %s: %w", f, err)
}
@@ -78,28 +88,44 @@ func newRenderer() (*renderer, error) {
return r, nil
}
-// render writes one page.
+// render writes one page in the request's chrome (chrome.go).
+func (a *App) render(w http.ResponseWriter, r *http.Request, status int, page string, data any) {
+ a.renderPage(w, r, status, page, data)
+}
+
+// renderPage writes one page.
//
// It renders to a buffer first. A template that fails halfway through
// would otherwise have already written a 200 and half a page, and the
// error handler could only append to it — so the status code would say
-// the opposite of what happened.
-func (a *App) render(w http.ResponseWriter, status int, page string, data any) {
- t, ok := a.renderer.pages[page]
+// the opposite of what happened. A page that fails is answered with the
+// 500 page; if that one fails too, with plain text.
+func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, name string, data any) {
+ t, ok := a.renderer.pages[name]
if !ok {
- a.logger.Error("no such page template", "page", page)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ a.logger.Error("no such page template", "page", name)
+ a.renderFailed(w, r, name)
return
}
var buf bytes.Buffer
- if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
- a.logger.Error("render", "page", page, "err", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ if err := t.ExecuteTemplate(&buf, "layout", a.pageFor(r, data, doorPages[name], hopPages[name])); err != nil {
+ a.logger.Error("render", "page", name, "err", err)
+ a.renderFailed(w, r, name)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if _, err := buf.WriteTo(w); err != nil {
- a.logger.Warn("write response", "page", page, "err", err)
+ a.logger.Warn("write response", "page", name, "err", err)
+ }
+}
+
+// renderFailed answers a page that could not be drawn: the 500 page,
+// unless the page that failed was the error page itself.
+func (a *App) renderFailed(w http.ResponseWriter, r *http.Request, name string) {
+ if name == "error.html" || r == nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
}
+ a.serverError(w, r)
}
diff --git a/internal/home/shared_chrome_test.go b/internal/home/shared_chrome_test.go
new file mode 100644
index 0000000..95feba1
--- /dev/null
+++ b/internal/home/shared_chrome_test.go
@@ -0,0 +1,302 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/home/internal/home/sealtest"
+)
+
+// The shared chrome (estilo spec shared-chrome, §12.1 row 8, Home): every
+// page Home draws for a person, other than the public front page, sits
+// under estilo's 52px bar with the brand "Oficina". On the directory, a
+// signed-in person has one account menu (who, Passkeys and Recovery codes
+// on id.*, Appearance, Help → Open source, one Sign out); signed out, the
+// corner is "Sign in". id.* pages are the door: the bar carries the brand
+// only, linking back to the directory.
+
+// chromeGet is a GET with an Accept header (empty sends none).
+func chromeGet(h http.Handler, target, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(http.MethodGet, target, nil)
+ if accept != "" {
+ r.Header.Set("Accept", accept)
+ }
+ for _, c := range cookies {
+ if c != nil {
+ r.AddCookie(c)
+ }
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, r)
+ return rec
+}
+
+var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`)
+
+func titleOf(body string) string {
+ m := pageTitle.FindStringSubmatch(body)
+ if m == nil {
+ return ""
+ }
+ return m[1]
+}
+
+const acceptHTML = "text/html,application/xhtml+xml"
+
+func TestHomePagesWearTheSharedBar(t *testing.T) {
+ f := newMountedChooser(t)
+ directory := f.p.directory.Router()
+ credential := f.p.credential.Router()
+ person := f.session(t, chooserPerson)
+ idSession := signInFully(t, f.p.credential, sealtest.CanaryAddress)
+
+ type want struct {
+ title string
+ account bool // the account menu, and with it exactly one Sign out
+ signIn bool // the signed-out corner's "Sign in"
+ sky bool
+ brand string // the brand link's href
+ }
+ cases := []struct {
+ name string
+ h http.Handler
+ target string
+ cookie *http.Cookie
+ want want
+ }{
+ {"directory", directory, directoryOrigin + "/", person, want{"Oficina", true, false, true, "/"}},
+ {"team chooser", directory, directoryOrigin + docsEntryPath, person, want{"No teams available · Oficina", true, false, false, "/"}},
+ {"open source, signed in", directory, directoryOrigin + "/help/open-source", person, want{"Open source · Oficina", true, false, false, "/"}},
+ {"open source, signed out", directory, directoryOrigin + "/help/open-source", nil, want{"Open source · Oficina", false, true, false, "/"}},
+ {"signed out", directory, directoryOrigin + "/signed-out", nil, want{"Signed out · Oficina", false, false, true, "/"}},
+ {"id.* sign in", credential, Origin + "/signin", nil, want{"Sign in · Oficina", false, false, true, directoryOrigin + "/"}},
+ {"id.* check your email", credential, Origin + "/signin/sent", nil, want{"Check your email · Oficina", false, false, true, directoryOrigin + "/"}},
+ {"id.* recovery codes", credential, Origin + "/passkey/codes", idSession, want{"Recovery codes · Oficina", false, false, true, directoryOrigin + "/"}},
+ {"id.* add a passkey", credential, Origin + "/passkey/enrol", idSession, want{"Add a passkey · Oficina", false, false, true, directoryOrigin + "/"}},
+ {"id.* open source", credential, Origin + "/help/open-source", nil, want{"Open source · Oficina", false, false, false, directoryOrigin + "/"}},
+ {"id.* sign out", credential, Origin + "/signout", nil, want{"Finish signing out · Oficina", false, false, true, directoryOrigin + "/"}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ rec := chromeGet(c.h, c.target, acceptHTML, c.cookie)
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if got := titleOf(body); got != c.want.title {
+ t.Errorf("title %q, want %q", got, c.want.title)
+ }
+ if strings.Count(body, "<header estilo-bar") != 1 {
+ t.Errorf("want exactly one estilo-bar")
+ }
+ if !strings.Contains(body, `<a estilo-bar-brand href="`+c.want.brand+`">`) || !strings.Contains(body, `<span estilo-bar-name>Oficina</span>`) {
+ t.Errorf("the brand is not \"Oficina\" linking to %s", c.want.brand)
+ }
+ if strings.Contains(body, "rst-dropdown") || strings.Contains(body, "home-hub-head><h1 estilo-brand") {
+ t.Errorf("the old lone Help menu or hub header is still drawn")
+ }
+ if got := strings.Contains(body, "<details estilo-account"); got != c.want.account {
+ t.Errorf("account menu: %v, want %v", got, c.want.account)
+ }
+ signOuts := strings.Count(body, ">Sign out<")
+ if c.want.account {
+ if signOuts != 1 {
+ t.Errorf("%d Sign out buttons, want exactly one", signOuts)
+ }
+ for _, item := range []string{
+ `href="` + Origin + `/passkey/enrol"`,
+ `href="` + Origin + `/passkey/codes"`,
+ `action="/appearance"`,
+ `href="/help/open-source"`,
+ `<form method="post" action="/signout">`,
+ } {
+ if !strings.Contains(body, item) {
+ t.Errorf("the account menu lacks %s", item)
+ }
+ }
+ } else if c.name != "id.* sign out" && signOuts != 0 {
+ t.Errorf("%d Sign out buttons with no account menu", signOuts)
+ }
+ if got := strings.Contains(body, "estilo-bar-signin"); got != c.want.signIn {
+ t.Errorf("Sign in corner: %v, want %v", got, c.want.signIn)
+ }
+ if got := strings.Contains(body, "<div estilo-sky "); got != c.want.sky {
+ t.Errorf("sky: %v, want %v (sign-in, team home header and front page only)", got, c.want.sky)
+ }
+ })
+ }
+}
+
+// The front page keeps its own design: no app bar. Its title is the hub's
+// name alone (spec §10), not "Oficina — open your new office suite".
+func TestFrontPageKeepsItsOwnDesign(t *testing.T) {
+ f := newMountedChooser(t)
+ body := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML).Body.String()
+ if strings.Contains(body, "estilo-bar") {
+ t.Error("the public front page has the app bar")
+ }
+ if got := titleOf(body); got != "Oficina" {
+ t.Errorf("front page title %q, want \"Oficina\"", got)
+ }
+}
+
+// Appearance: a signed-in person's choice, kept per host (spec §8.3).
+func TestAppearanceSetsTheTheme(t *testing.T) {
+ f := newMountedChooser(t)
+ person := f.session(t, chooserPerson)
+ rec := f.request("POST", "/appearance", directoryOrigin, url.Values{"mode": {"dark"}, "return_to": {"/"}}.Encode(), person)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
+ t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+ var mode *http.Cookie
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == "estilo_appearance" {
+ mode = c
+ }
+ }
+ if mode == nil || mode.Value != "dark" {
+ t.Fatalf("no estilo_appearance=dark cookie: %v", rec.Result().Cookies())
+ }
+ body := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML, person, mode).Body.String()
+ if !strings.Contains(body, `<html lang="en" data-theme="dark">`) {
+ t.Errorf("the directory does not draw the chosen theme: %.120s", body)
+ }
+ if !strings.Contains(body, `value="dark" aria-pressed="true"`) {
+ t.Error("Dark is not shown as the current appearance")
+ }
+}
+
+// estilo's error page on both origins, for a browser; everything else
+// keeps a plain-text answer (spec §3.3). The 404 used to be chi's bare
+// "404 page not found" whatever was asking.
+func TestErrorPagesSplitByAccept(t *testing.T) {
+ f := newMountedChooser(t)
+ person := f.session(t, chooserPerson)
+ cases := []struct {
+ name string
+ h http.Handler
+ target string
+ cookie *http.Cookie
+ account bool
+ }{
+ {"directory, signed in", f.p.directory.Router(), directoryOrigin + "/no/such/page", person, true},
+ {"directory, signed out", f.p.directory.Router(), directoryOrigin + "/no/such/page", nil, false},
+ {"directory, GET to a POST-only path", f.p.directory.Router(), directoryOrigin + "/appearance", person, true},
+ {"id.*", f.p.credential.Router(), Origin + "/no/such/page", nil, false},
+ {"id.*, GET to a POST-only path", f.p.credential.Router(), Origin + "/passkey/signin/begin", nil, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ rec := chromeGet(c.h, c.target, acceptHTML, c.cookie)
+ body := rec.Body.String()
+ if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
+ t.Fatalf("browser: %d %q, want a 404 page", rec.Code, rec.Header().Get("Content-Type"))
+ }
+ if titleOf(body) != "Page not found · Oficina" || !strings.Contains(body, "<div rst-error>") || !strings.Contains(body, "We can’t find that page") || !strings.Contains(body, "<header estilo-bar") {
+ t.Errorf("not estilo's error page under the bar: %q", titleOf(body))
+ }
+ if got := strings.Contains(body, "<details estilo-account"); got != c.account {
+ t.Errorf("account menu %v, want %v", got, c.account)
+ }
+ if rec.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control %q", rec.Header().Get("Cache-Control"))
+ }
+ for _, accept := range []string{"", "*/*", "application/json"} {
+ rec := chromeGet(c.h, c.target, accept, c.cookie)
+ if (rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed) || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
+ t.Errorf("Accept %q: %d %q, want a plain 404 or 405", accept, rec.Code, rec.Header().Get("Content-Type"))
+ }
+ }
+ })
+ }
+ // An unknown host is not a page anyone navigates to: still empty.
+ rec := chromeGet(f.p.directory.Router(), "https://elsewhere.example/no/such/page", acceptHTML)
+ if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
+ t.Errorf("unknown host: %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+// "Use a different address" and "Start again" keep where you were going
+// (r3 C1). Both were a bare /signin, which forgets return_to: the magic
+// link then ended at id.* "/", which sends you to the signed-out front
+// page, and a sign-in from an app lost the app.
+func TestStartingAgainKeepsTheDestination(t *testing.T) {
+ app := newTestApp(t)
+ h := app.Router()
+ form := chromeGet(h, Origin+"/signin?return_to=%2Fauthorize%2Fresume", acceptHTML)
+ var keep *http.Cookie
+ for _, c := range form.Result().Cookies() {
+ if c.Name == returnCookie && c.Value != "" {
+ keep = c
+ }
+ }
+ if keep == nil {
+ t.Fatal("the form did not remember return_to")
+ }
+ const again = `href="/signin?return_to=%2Fauthorize%2Fresume"`
+ sent := chromeGet(h, Origin+"/signin/sent", acceptHTML, keep).Body.String()
+ if !strings.Contains(sent, again+">Use a different address") {
+ t.Errorf("Use a different address forgets the destination:\n%s", sent)
+ }
+ r := httptest.NewRequest(http.MethodPost, Origin+linkPath, strings.NewReader("token=nope"))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", Origin)
+ r.AddCookie(keep)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, r)
+ if !strings.Contains(rec.Body.String(), again+">Start again") {
+ t.Errorf("Start again forgets the destination:\n%s", rec.Body.String())
+ }
+ // With nowhere remembered, both stay a plain /signin.
+ if body := chromeGet(h, Origin+"/signin/sent", acceptHTML).Body.String(); !strings.Contains(body, `href="/signin">Use a different address`) {
+ t.Errorf("with no destination: %s", body)
+ }
+}
+
+// The hand-off hop keeps the sky's inline placeholder (r3 C2): it used to
+// draw neither sky nor card, so every sign-in flashed a blank page between
+// two skies. Only the placeholder: it is a data: URI, so the hop still
+// fetches nothing it will not show.
+func TestHopKeepsTheSkyPlaceholder(t *testing.T) {
+ p := newExchangePair(t)
+ credential := signInFully(t, p.credential, sealtest.CanaryAddress)
+ _, intent := p.start(t)
+ rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, credential)
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK || !strings.Contains(body, `name="assertion"`) {
+ t.Fatalf("resume: %d", rec.Code)
+ }
+ if !strings.Contains(body, `estilo-sky-layer="placeholder" estilo-sky-tone="light" src="data:`) {
+ t.Error("the hop draws no sky")
+ }
+ if strings.Contains(body, "grain.svg") || strings.Contains(body, "filaments.svg") || strings.Contains(body, "busy.js") {
+ t.Error("the hop fetches sky layers or scripts it will not show")
+ }
+}
+
+// A cold first load paints the mark and Onest at once (r3 C4): the font
+// and the brand pictures are preloaded, on the front page and every page
+// in the layout.
+func TestColdLoadPreloadsTheFontAndMarks(t *testing.T) {
+ f := newMountedChooser(t)
+ font := `<link rel="preload" href="/static/estilo/fonts/v1/onest-latin.woff2" as="font" type="font/woff2" crossorigin>`
+ front := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML).Body.String()
+ signin := chromeGet(f.p.credential.Router(), Origin+"/signin", acceptHTML).Body.String()
+ for name, body := range map[string]string{"front page": front, "id.* sign in": signin} {
+ if !strings.Contains(body, font) {
+ t.Errorf("%s: Onest is not preloaded", name)
+ }
+ if !strings.Contains(body, `<link rel="preload" href="/static/brand/icon.svg?v=`) {
+ t.Errorf("%s: the Oficina mark is not preloaded", name)
+ }
+ }
+ for _, app := range []string{"docs", "sheets", "slides", "calendar", "calls", "memoria"} {
+ if !strings.Contains(front, `<link rel="preload" href="/static/brand/`+app+`.svg?v=`) {
+ t.Errorf("front page: %s's mark is not preloaded", app)
+ }
+ }
+}
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 806797f..adee4bc 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
+ "net/url"
"strings"
"time"
@@ -161,14 +162,25 @@ func clientIP(r *http.Request) string {
// handleSigninForm renders the address form.
func (a *App) handleSigninForm(w http.ResponseWriter, r *http.Request) {
rememberReturn(w, r)
- a.render(w, http.StatusOK, "signin.html", map[string]any{})
+ a.render(w, r, http.StatusOK, "signin.html", map[string]any{})
}
// handleSigninSent is the check-your-mail page. It is reached by
// redirect and asserts nothing about the address, so it is the same page
// whether or not anybody has an account here.
func (a *App) handleSigninSent(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "check_your_mail.html", map[string]any{})
+ a.render(w, r, http.StatusOK, "check_your_mail.html", map[string]any{"Again": againHref(r)})
+}
+
+// againHref is the way back to the form that keeps where the sign-in was
+// going: "Use a different address" and "Start again" (r3 C1). A bare
+// /signin forgets return_to (rememberReturn clears it), so the next link
+// ended at id.* "/" and the person on the signed-out front page.
+func againHref(r *http.Request) string {
+ if target := readReturn(r); target != "/" {
+ return "/signin?return_to=" + url.QueryEscape(target)
+ }
+ return "/signin"
}
// handleSigninBegin starts a sign-in.
@@ -180,7 +192,7 @@ func (a *App) handleSigninSent(w http.ResponseWriter, r *http.Request) {
// neither discloses whether that person has an account here.
func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- a.render(w, http.StatusBadRequest, "signin.html",
+ a.render(w, r, http.StatusBadRequest, "signin.html",
map[string]any{"Error": "That did not look like a form."})
return
}
@@ -197,25 +209,25 @@ func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
next, err := a.flow.Begin(r.Context(), address, clientIP(r), force)
switch {
case errors.Is(err, signin.ErrBadAddress):
- a.render(w, http.StatusBadRequest, "signin.html",
+ a.render(w, r, http.StatusBadRequest, "signin.html",
map[string]any{"Error": "That does not look like an email address."})
return
case errors.Is(err, signin.ErrRateLimited):
// 429 without saying which budget: which one was hit is itself
// information about whether that address has been tried.
- a.render(w, http.StatusTooManyRequests, "signin.html",
+ a.render(w, r, http.StatusTooManyRequests, "signin.html",
map[string]any{"Error": "Too many attempts. Try again shortly."})
return
case err != nil:
a.logger.Error("signin begin", "err", err)
- a.render(w, http.StatusInternalServerError, "signin.html",
+ a.render(w, r, http.StatusInternalServerError, "signin.html",
map[string]any{"Error": "Something went wrong. Try again."})
return
}
if next.Kind == signin.NextKeymail {
a.setPending(w, next.Pending)
- a.renderContinuation(w, next.Redirect, "Continue to Keymail")
+ a.renderContinuation(w, r, next.Redirect, "Continue to Keymail")
return
}
http.Redirect(w, r, "/signin/sent", http.StatusSeeOther)
@@ -236,14 +248,14 @@ func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
// from an invalid one, which is exactly the difference this page exists
// not to have.
func (a *App) handleVerifyForm(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "signin_confirm.html",
+ a.render(w, r, http.StatusOK, "signin_confirm.html",
map[string]any{"Token": r.URL.Query().Get("token")})
}
// handleVerify redeems a magic link. Behind csrf.Protect, on POST.
func (a *App) handleVerify(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
id, err := a.flow.CompleteMagicLink(r.Context(), r.PostFormValue("token"))
@@ -251,7 +263,7 @@ func (a *App) handleVerify(w http.ResponseWriter, r *http.Request) {
// Unknown, expired and already-used are one answer. TakeLink
// makes them one answer at the store; this keeps them one
// answer at the door.
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
a.completeSignIn(w, r, id)
@@ -305,7 +317,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
ref, err := a.findOrMintPerson(r.Context(), id.Address)
if err != nil {
a.logger.Error("find or mint person", "err", err)
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
sess := sessions.Session{
@@ -331,7 +343,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
gated, err := a.passkeys.Gate(w, requestWithReturn(r, target), sess)
if err != nil {
a.logger.Error("passkey gate", "person", ref, "err", err)
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
if gated {
@@ -340,7 +352,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
if err := a.sessions.SignIn(w, r, sess); err != nil {
a.logger.Error("mint session", "person", ref, "err", err)
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
@@ -349,8 +361,8 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
// signinFailed is the one answer every failed redemption gets. One page,
// one status, no detail: unknown, expired, already used and
// wrong-purpose must be indistinguishable from outside.
-func (a *App) signinFailed(w http.ResponseWriter) {
- a.render(w, http.StatusBadRequest, "signin_failed.html", map[string]any{})
+func (a *App) signinFailed(w http.ResponseWriter, r *http.Request) {
+ a.render(w, r, http.StatusBadRequest, "signin_failed.html", map[string]any{"Again": againHref(r)})
}
func (a *App) setPending(w http.ResponseWriter, blob string) {
diff --git a/internal/home/signout.go b/internal/home/signout.go
index 246e22f..c7da8e2 100644
--- a/internal/home/signout.go
+++ b/internal/home/signout.go
@@ -54,14 +54,14 @@ func (a *App) handleCredentialSignout(w http.ResponseWriter, r *http.Request) {
back, verdict := a.signoutReturn(r)
switch verdict {
case signoutRefused:
- a.render(w, http.StatusForbidden, "signout_confirm.html", nil)
+ a.render(w, r, http.StatusForbidden, "signout_confirm.html", nil)
return
case signoutConfirm:
// Admitted, but the id.* cookie did not ride this hop, so there is
// no row to revoke yet. Clearing the cookie now would leave the
// row alive behind it; keep it, and let the same-origin button
// (which does carry the cookie) end the session.
- a.render(w, http.StatusOK, "signout_confirm.html", nil)
+ a.render(w, r, http.StatusOK, "signout_confirm.html", nil)
return
}
a.sessions.SignOut(w, r)
@@ -73,7 +73,7 @@ func (a *App) handleCredentialSignout(w http.ResponseWriter, r *http.Request) {
// a GET is one any link or <img> can trigger.
func (a *App) handleCredentialSignoutPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
- a.render(w, http.StatusOK, "signout_confirm.html", nil)
+ a.render(w, r, http.StatusOK, "signout_confirm.html", nil)
}
// signoutReturn admits a sign-out POST from this origin's own pages or
diff --git a/internal/home/static/calma.css b/internal/home/static/calma.css
index be2365a..0e54152 100644
--- a/internal/home/static/calma.css
+++ b/internal/home/static/calma.css
@@ -1,6 +1,6 @@
-/* Home's own Calma rules: where the sky sits on Home's pages, and the
- card the pages at the door are drawn as. Everything else is estilo's
- theme (theme.css) and rastrillo's tokens.css. */
+/* Home's own Calma rules: the team home's header on the sky, and its
+ grid of apps. The bar, the menus and the door are estilo's (chrome.css),
+ the rest estilo's theme (theme.css) and rastrillo's tokens.css. */
/* tokens.css reserves the scrollbar's gutter on every page, so short and
long pages are the same width. Under the sky that reserved strip is a
@@ -10,67 +10,38 @@
--rst-scrollbar-gutter: auto;
}
-/* The sky hangs behind the top of every Home page, fading out before the
- content gets busy. The host is at least as tall as the part of the sky
- that shows, so a short page does not clip it. */
-[home-sky] {
- --estilo-sky-top: -140px;
- --estilo-sky-height: 560px;
- min-height: 420px;
-}
+/* The door (sign-in, id.*'s account pages, signed out) is estilo's:
+ chrome.css draws the card on the sky under the bar. */
-/* At the door: one card on the sky, the Oficina mark at its head. */
-main[home-door] {
- box-sizing: border-box;
- max-width: 460px;
- margin: max(40px, 11vh) auto 48px;
- padding: 34px 40px 32px;
- background: var(--rst-surface);
- border-radius: 20px;
- box-shadow: 0 0 0 1px light-dark(transparent, var(--rst-line)), var(--rst-shadow-lift);
-}
-main[home-door] > [estilo-brand] {
- margin: 0 0 22px;
- font-size: 1.2rem;
- font-weight: 650;
- letter-spacing: -0.01em;
-}
-main[home-door] h1 {
- letter-spacing: -0.02em;
-}
-@media (max-width: 520px) {
- main[home-door] {
- margin: 28px 14px 40px;
- padding: 26px 22px 24px;
- }
-}
+/* Your home: the team home's header on the sky, then a grid of every
+ app, each one tile. An app you can open is a link across its whole
+ tile; one you cannot yet is shown quieter, with its status saying why.
-/* Your home: the Oficina mark and a grid of every app, each one tile. An
- app you can open is a link across its whole tile; one you cannot yet
- is shown quieter, with its status saying why. */
+ The header spans the page under the bar, as the door does, so the sky
+ is never boxed in; its padding puts the words back on the page's
+ column. The sky fades in and out inside the band (sky.css's mask), so
+ neither edge cuts it. */
+:root:has([home-hub-head]) { overflow-x: clip; }
[home-hub-head] {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- margin: 28px 0 8px;
+ box-sizing: border-box;
+ margin: calc(-1 * var(--rst-sp-5)) calc(50% - 50vw) var(--rst-sp-5);
+ padding: 64px calc(50vw - 50%) 48px;
+ min-block-size: 240px;
+ /* A sky's layers overhang its box by 12% above and below and fade out
+ from 58% to 92% (sky.css): 260px placed 50px up has faded out by
+ about 220px, inside the band's 240. */
+ --estilo-sky-top: -50px;
+ --estilo-sky-height: 260px;
}
[home-hub-head] h1 {
- margin: 0;
+ margin: 0 0 8px;
font-size: clamp(1.9rem, 4vw, 2.5rem);
letter-spacing: -0.03em;
}
-[home-hub-head] form { margin: 0; }
[home-hub-lede] {
- margin: 0 0 40px;
+ margin: 0;
color: var(--rst-text-muted);
}
-[home-hub-h] {
- margin: 0 0 12px;
- color: var(--rst-text-faint);
- font-size: 0.95rem;
- font-weight: 600;
-}
[home-apps] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -101,11 +72,15 @@ a[home-app]:hover { box-shadow: 0 0 0 1px var(--rst-line), var(--rst-shadow-lift
div[home-app] img { filter: saturate(0.6); opacity: 0.8; }
/* In dark, the sky runs brighter (estilo's gain) and muted text on its
- brightest band falls under 4.5:1 (review, discussion 75). So the sky
- is quieter here, and text that sits on it is full-strength. */
+ brightest band falls under 4.5:1 (review, discussion 75). So the team
+ home's sky is quieter here, and text that sits on it is full-strength.
+ The door's text sits on its card, so the door keeps estilo's sky. */
@media (prefers-color-scheme: dark) {
- :root:not([data-theme="light"]) [home-sky] { --estilo-sky-strength: 0.32; }
+ :root:not([data-theme="light"]) [home-hub-head] { --estilo-sky-strength: 0.32; }
:root:not([data-theme="light"]) [home-hub-lede] { color: var(--rst-text); }
}
-:root[data-theme="dark"] [home-sky] { --estilo-sky-strength: 0.32; }
+:root[data-theme="dark"] [home-hub-head] { --estilo-sky-strength: 0.32; }
:root[data-theme="dark"] [home-hub-lede] { color: var(--rst-text); }
+
+/* The team chooser: one button per team, stacked. */
+[home-teams] { display: grid; justify-items: start; gap: var(--rst-sp-2); }
diff --git a/internal/home/static/site/index.html b/internal/home/static/site/index.html
index 59719dd..1f6695d 100644
--- a/internal/home/static/site/index.html
+++ b/internal/home/static/site/index.html
@@ -3,8 +3,16 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Oficina — open your new office suite</title>
+<title>Oficina</title>
<meta name="description" content="Oficina is a fast, lightweight office suite that values your privacy. Docs, Sheets, Slides, Calendar, Calls and Memoria, in the browser and as native Mac apps.">
+<link rel="preload" href="/static/estilo/fonts/v1/onest-latin.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/static/brand/icon.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/docs.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/sheets.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/slides.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/calendar.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/calls.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/memoria.svg" as="image" type="image/svg+xml">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
diff --git a/internal/home/templates/assertion.html b/internal/home/templates/assertion.html
index 6b1e5e6..3c7887a 100644
--- a/internal/home/templates/assertion.html
+++ b/internal/home/templates/assertion.html
@@ -1,10 +1,9 @@
{{define "title"}}Continue to Oficina · Oficina{{end}}
{{define "head"}}
-{{/* The page submits itself (static/assertion.js), so its fallback copy
- stays hidden for 1.5 s and shows only if the submit stalls. With
- scripts off nothing submits, so noscript shows it at once. Spec
- shared-chrome §9.3; the rule moves to estilo chrome.css when the
- id.* pages adopt the shared chrome. */}}
+{{/* The page submits itself (static/assertion.js), so its card stays
+ hidden for 1.5 s and shows only if the submit stalls; the bar and the
+ sky's placeholder show at once (layout.html, hop). With scripts off nothing submits, so noscript shows it
+ at once. Spec shared-chrome §9.3. */}}
<style>
[estilo-handoff] { animation: estilo-reveal 0s 1.5s both; }
@keyframes estilo-reveal { from { visibility: hidden } to { visibility: visible } }
@@ -12,13 +11,11 @@
<noscript><style>[estilo-handoff] { animation: none; }</style></noscript>
{{end}}
{{define "content"}}
-<div estilo-handoff>
<h1>Continue to Oficina</h1>
-<p>Select Continue if this page does not move on.</p>
-<form method="post" action="{{.Action}}" rst-form data-assertion-handoff>
-<input type="hidden" name="assertion" value="{{.Token}}">
+<p estilo-signin-body>Select Continue if this page doesn’t move on.</p>
+<form method="post" action="{{.Data.Action}}" rst-form data-assertion-handoff>
+<input type="hidden" name="assertion" value="{{.Data.Token}}">
<button type="submit" rst-btn="primary">Continue</button>
</form>
-</div>
{{end}}
{{define "scripts"}}<script src="/static/assertion.js" defer></script>{{end}}
diff --git a/internal/home/templates/check_your_mail.html b/internal/home/templates/check_your_mail.html
index 517f714..740f16a 100644
--- a/internal/home/templates/check_your_mail.html
+++ b/internal/home/templates/check_your_mail.html
@@ -1,6 +1,6 @@
-{{define "title"}}{{sentDoor.Title}}{{end}}
-{{define "content"}}{{with sentDoor}}
+{{define "title"}}{{(sentDoor .Data.Again).Title}}{{end}}
+{{define "content"}}{{with sentDoor .Data.Again}}
<h1>{{.Heading}}</h1>
-<p>{{.Body}}</p>
-<p><a href="{{.Link.Href}}">{{.Link.Label}}</a></p>
+<p estilo-signin-body role="status">{{.Body}}</p>
+{{with .Link}}<p><a href="{{.Href}}">{{.Label}}</a></p>{{end}}
{{end}}{{end}}
diff --git a/internal/home/templates/credits.html b/internal/home/templates/credits.html
index 596f7dd..11b27e4 100644
--- a/internal/home/templates/credits.html
+++ b/internal/home/templates/credits.html
@@ -1,5 +1,3 @@
{{define "title"}}Open source · Oficina{{end}}
{{define "head"}}<link rel="stylesheet" href="{{v "/static/credits.css"}}">{{end}}
-{{define "content"}}{{template "estilo/credits" .}}{{end}}
-
-{{define "help"}}{{template "home/help" .}}{{end}}
+{{define "content"}}{{template "estilo/credits" .Data}}{{end}}
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html
index a085be3..6ea2d73 100644
--- a/internal/home/templates/directory.html
+++ b/internal/home/templates/directory.html
@@ -1,14 +1,14 @@
{{define "title"}}Oficina{{end}}
{{define "content"}}
-<header home-hub-head>
-<h1 estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="40" height="40">Oficina</h1>
-<form method="post" action="/signout"><button type="submit" rst-btn>Sign out</button></form>
+{{/* The team home's header: the one place inside the hub, beside the
+ door and the front page's hero, where Calma's sky goes. */}}
+<header home-hub-head estilo-sky-host>
+{{template "estilo-sky" suiteSky}}
+<h1>Your apps</h1>
+<p home-hub-lede>Every app here opens with the account you’re signed in with.</p>
</header>
-<p home-hub-lede>You are signed in. Every app below opens with this one account.</p>
-<section aria-labelledby="apps-heading">
-<h2 id="apps-heading" home-hub-h>Apps</h2>
-<ul home-apps>
-{{range .Apps}}
+<ul home-apps aria-label="Apps">
+{{range .Data.Apps}}
<li>{{if .Href}}<a home-app href="{{.Href}}">{{else}}<div home-app>{{end}}
{{if .Icon}}<img src="{{.Icon}}" alt="" width="56" height="56">{{end}}
<b>{{.Name}}</b>
@@ -16,7 +16,4 @@
{{if .Href}}</a>{{else}}</div>{{end}}</li>
{{end}}
</ul>
-</section>
{{end}}
-
-{{define "help"}}{{template "home/help" .}}{{end}}
diff --git a/internal/home/templates/directory_signed_out.html b/internal/home/templates/directory_signed_out.html
index 19d79fe..d427a1a 100644
--- a/internal/home/templates/directory_signed_out.html
+++ b/internal/home/templates/directory_signed_out.html
@@ -1,6 +1,6 @@
{{define "title"}}{{signedOutDoor.Title}}{{end}}
{{define "content"}}{{with signedOutDoor}}
<h1>{{.Heading}}</h1>
-<p>{{.Body}}</p>
-<a href="{{.Link.Href}}" rst-btn="primary">{{.Link.Label}}</a>
+<p estilo-signin-body>{{.Body}}</p>
+<p><a href="{{.Link.Href}}" rst-btn="primary">{{.Link.Label}}</a></p>
{{end}}{{end}}
diff --git a/internal/home/templates/docs_chooser.html b/internal/home/templates/docs_chooser.html
index eec274c..6d708fe 100644
--- a/internal/home/templates/docs_chooser.html
+++ b/internal/home/templates/docs_chooser.html
@@ -1,9 +1,9 @@
{{define "title"}}Choose a team · Oficina{{end}}
{{define "content"}}
<h1>Choose a team</h1>
-<p>Open {{.AppName}} for one of your teams.</p>
-<form method="post" action="{{.ChoicePath}}" rst-form>
-{{range .Choices}}
+<p>Open {{.Data.AppName}} for one of your teams.</p>
+<form method="post" action="{{.Data.ChoicePath}}" rst-form home-teams>
+{{range .Data.Choices}}
<button type="submit" name="team" value="{{.SQID}}" rst-btn>{{.Name}}</button>
{{end}}
</form>
diff --git a/internal/home/templates/docs_continue.html b/internal/home/templates/docs_continue.html
index 89695a2..255e33b 100644
--- a/internal/home/templates/docs_continue.html
+++ b/internal/home/templates/docs_continue.html
@@ -1,6 +1,6 @@
-{{define "title"}}{{.Title}}{{end}}
-{{define "head"}}<meta http-equiv="refresh" content="0;url={{.Destination}}">{{end}}
+{{define "title"}}{{.Data.Title}} · Oficina{{end}}
+{{define "head"}}<meta http-equiv="refresh" content="0;url={{.Data.Destination}}">{{end}}
{{define "content"}}
-<h1>{{.Title}}</h1>
-<p><a href="{{.Destination}}" referrerpolicy="no-referrer">{{.Title}}</a></p>
+<h1>{{.Data.Title}}</h1>
+<p estilo-signin-body><a href="{{.Data.Destination}}" referrerpolicy="no-referrer">{{.Data.Title}}</a></p>
{{end}}
diff --git a/internal/home/templates/docs_empty.html b/internal/home/templates/docs_empty.html
index d3df80d..dea27b9 100644
--- a/internal/home/templates/docs_empty.html
+++ b/internal/home/templates/docs_empty.html
@@ -1,6 +1,6 @@
{{define "title"}}No teams available · Oficina{{end}}
{{define "content"}}
<h1>No teams available</h1>
-<p>This account has no team with {{.AppName}} available.</p>
+<p>This account has no team with {{.Data.AppName}} available.</p>
<p><a href="/">Back to Oficina</a></p>
{{end}}
diff --git a/internal/home/templates/error.html b/internal/home/templates/error.html
new file mode 100644
index 0000000..1f20e63
--- /dev/null
+++ b/internal/home/templates/error.html
@@ -0,0 +1,2 @@
+{{define "title"}}{{.Data.Title}}{{end}}
+{{define "content"}}{{template "estilo-error" .Data.Error}}{{end}}
diff --git a/internal/home/templates/handoff_failed.html b/internal/home/templates/handoff_failed.html
index fa7ddcd..a920954 100644
--- a/internal/home/templates/handoff_failed.html
+++ b/internal/home/templates/handoff_failed.html
@@ -1,6 +1,6 @@
{{define "title"}}Could not finish signing in · Oficina{{end}}
{{define "content"}}
<h1>Could not finish signing in</h1>
-<p>Return to Oficina and open the app again.</p>
-<p><a href="{{.HomeURL}}">Back to Oficina</a></p>
+<p estilo-signin-body>Return to Oficina and open the app again.</p>
+<p><a href="{{.Data.HomeURL}}">Back to Oficina</a></p>
{{end}}
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html
index 273382f..a3493f5 100644
--- a/internal/home/templates/layout.html
+++ b/internal/home/templates/layout.html
@@ -1,28 +1,49 @@
{{define "layout"}}<!doctype html>
-<html lang="{{block "lang" .}}en{{end}}">
+<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
<head>
-<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}">
-<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}">
-<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}">
-<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Oficina{{end}}</title>
+{{/* A cold first load paints in Onest and with the mark (r3 C4): both are
+ fetched before the stylesheets that name them are parsed. */}}
+<link rel="preload" href="{{fontPreload}}" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="{{v "/static/brand/icon.svg"}}" as="image" type="image/svg+xml">
+<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}">
+<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}">
+<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}">
+{{/* The spec's order (shared-chrome §0.5): tokens, theme, brand, estilo's
+ base, menu and chrome, then the sky and Home's own rules last. */}}
<link rel="stylesheet" href="{{fontsCSS}}">
<link rel="stylesheet" href="{{v "/static/tokens.css"}}">
<link rel="stylesheet" href="{{v "/static/theme.css"}}">
+<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/base.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/menu.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/chrome.css"}}">
<link rel="stylesheet" href="{{v "/static/busy.css"}}">
<link rel="stylesheet" href="{{v "/static/estilo/sky.css"}}">
<link rel="stylesheet" href="{{v "/static/calma.css"}}">
{{block "head" .}}{{end}}
</head>
<body{{block "bodyattrs" .}}{{end}}>
-<div estilo-sky-host home-sky>
-{{if not hop}}{{template "estilo-sky" suiteSky}}{{end}}
-{{block "help" .}}{{end}}
-<main rst-page{{if door}} home-door{{end}}>
-{{if door}}<p estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="36" height="36">Oficina</p>{{end}}
+<div estilo-shell>
+{{if not hop}}<a rst-skip href="#main">{{.Chrome.Labels.Skip}}</a>{{end}}
+{{template "estilo-bar" .Chrome}}
+<main rst-page id="main">
+{{- if door}}
+{{template "estilo-door-open" doorView}}
+{{template "content" .}}
+{{template "estilo-door-close"}}
+{{- else if hop}}
+<div estilo-door estilo-sky-host>
+{{template "estilo-sky" hopSky}}
+<section estilo-signin estilo-handoff>
+{{template "content" .}}
+</section>
+</div>
+{{- else}}
{{template "content" .}}
+{{- end}}
</main>
</div>
{{if not hop}}<script defer src='{{v "/static/busy.js"}}'></script>{{end}}
@@ -30,5 +51,3 @@
</body>
</html>
{{end}}
-
-{{define "home/help"}}<header rst-page><details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details></header>{{end}}
diff --git a/internal/home/templates/passkey_confirm.html b/internal/home/templates/passkey_confirm.html
index e6de3f2..5eb7ffa 100644
--- a/internal/home/templates/passkey_confirm.html
+++ b/internal/home/templates/passkey_confirm.html
@@ -1,9 +1,9 @@
{{define "title"}}Confirm it is you · Oficina{{end}}
-{{define "bodyattrs"}} data-rpid="{{.RPID}}" data-legacyrpid="{{.LegacyRPID}}"{{end}}
+{{define "bodyattrs"}} data-rpid="{{.Data.RPID}}" data-legacyrpid="{{.Data.LegacyRPID}}"{{end}}
{{define "content"}}
<h1>Confirm it is you</h1>
-{{if .RecoveryFailed}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>That code did not work.</span></p>{{end}}
+{{if .Data.RecoveryFailed}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>That code did not work.</span></p>{{end}}
<button id="passkey-confirm" type="button" rst-btn="primary" data-passkey hidden>Use your passkey</button>
<p id="passkey-status" role="status"></p>
diff --git a/internal/home/templates/passkey_enrol.html b/internal/home/templates/passkey_enrol.html
index a2edcb5..09a06d3 100644
--- a/internal/home/templates/passkey_enrol.html
+++ b/internal/home/templates/passkey_enrol.html
@@ -1,5 +1,5 @@
{{define "title"}}Add a passkey · Oficina{{end}}
-{{define "bodyattrs"}} data-rpid="{{.RPID}}" data-userid="{{.UserID}}" data-username="{{.UserName}}"{{end}}
+{{define "bodyattrs"}} data-rpid="{{.Data.RPID}}" data-userid="{{.Data.UserID}}" data-username="{{.Data.UserName}}"{{end}}
{{define "content"}}
<h1>Add a passkey</h1>
<p>A passkey is an optional second step. Without one, your email link is
diff --git a/internal/home/templates/recovery_codes.html b/internal/home/templates/recovery_codes.html
index d88ca24..36c0bc0 100644
--- a/internal/home/templates/recovery_codes.html
+++ b/internal/home/templates/recovery_codes.html
@@ -1,14 +1,14 @@
{{define "title"}}Recovery codes · Oficina{{end}}
{{define "content"}}
<h1>Recovery codes</h1>
-{{if .Codes}}
+{{if .Data.Codes}}
<p rst-callout rst-tone="warning"><span rst-callout-body><strong>These are shown once.</strong> Keep them somewhere your
passkey is not. Each one works a single time.</span></p>
-<ul>{{range .Codes}}<li><code>{{.}}</code></li>{{end}}</ul>
+<ul>{{range .Data.Codes}}<li><code>{{.}}</code></li>{{end}}</ul>
<p>Losing your passkey and these codes together locks you out permanently.
There is no operator who can undo that, by design.</p>
{{else}}
-<p>You have {{.Remaining}} unused recovery codes.</p>
+<p>You have {{.Data.Remaining}} unused recovery codes.</p>
<p>Generating a new set <strong>invalidates every code you have now</strong>.</p>
<form method="post" action="/passkey/codes">
<button type="submit" rst-btn="danger">Generate a new set</button>
diff --git a/internal/home/templates/signin.html b/internal/home/templates/signin.html
index 373cdff..b871679 100644
--- a/internal/home/templates/signin.html
+++ b/internal/home/templates/signin.html
@@ -1,8 +1,8 @@
{{define "title"}}{{signinDoor.Title}}{{end}}
{{define "content"}}{{with signinDoor}}
<h1>{{.Heading}}</h1>
-{{if $.Error}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>{{$.Error}}</span></p>{{end}}
-<form method="post" action="/signin" rst-form>
+{{if $.Data.Error}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>{{$.Data.Error}}</span></p>{{end}}
+<form method="post" action="/signin" rst-form estilo-signin-form>
<div rst-field>
<label rst-field-label for="address">{{.AddressLabel}}</label>
<input id="address" name="address" type="email" autocomplete="email" required autofocus rst-input aria-describedby="address-help">
diff --git a/internal/home/templates/signin_confirm.html b/internal/home/templates/signin_confirm.html
index b27fe86..c70466e 100644
--- a/internal/home/templates/signin_confirm.html
+++ b/internal/home/templates/signin_confirm.html
@@ -3,7 +3,7 @@
<h1>Finish signing in</h1>
<p>Press the button to sign in on this device.</p>
<form method="post" action="/signin/verify" rst-form>
- <input type="hidden" name="token" value="{{.Token}}">
+ <input type="hidden" name="token" value="{{.Data.Token}}">
<div rst-form-foot>
<button type="submit" rst-btn="primary">Sign in</button>
</div>
diff --git a/internal/home/templates/signin_failed.html b/internal/home/templates/signin_failed.html
index d8494b5..3da5292 100644
--- a/internal/home/templates/signin_failed.html
+++ b/internal/home/templates/signin_failed.html
@@ -1,6 +1,6 @@
{{define "title"}}That link did not work · Oficina{{end}}
{{define "content"}}
<h1>That link did not work</h1>
-<p>Sign-in links work once and expire after {{linkLifetime}} minutes. Ask for a new one.</p>
-<p><a href="/signin">Start again</a></p>
+<p estilo-signin-body>Sign-in links work once and expire after {{linkLifetime}} minutes. Ask for a new one.</p>
+<p><a href="{{.Data.Again}}">Start again</a></p>
{{end}}