oficina / sheets Public

Merge branch 'signin-door' into main

Merge signin-door: sign-in is the Oficina door
claude agent, operated by paul@keymail.dev 2335fe1d8f4fde09c0bcfa9d19c209c322d042b7 parent fcd2580parent 1657f59
9 files changed, +103 −7
  • go.mod +1 −1
  • go.sum +4 −0
  • internal/sheets/estilo.go +2 −0
  • internal/sheets/home.go +24 −4
  • internal/sheets/render.go +4 −0
  • internal/sheets/signin.go +7 −0
  • internal/sheets/signin_door_test.go +58 −0
  • internal/sheets/templates/home_oauth.html +2 −2
  • internal/sheets/templates/layout.html +1 −0

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 ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.117s
ok  	amadan.net/oficina/sheets/internal/sheets	11.850s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.017s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.053s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/store	3.379s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.347s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	66.421s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.075s
diff --git a/go.mod b/go.mod
index 096e607..8c52105 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module amadan.net/oficina/sheets
go 1.25.0
require (
- amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c
+ amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
diff --git a/go.sum b/go.sum
index cb4ad0f..5683e5c 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,10 @@ amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjP
amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo=
amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b h1:UOxYBKsOSRHcHHYNKt0i6cB1Z4yT2soywOTWURYvBWo=
+amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22 h1:RR9qP85FU1cZdn788v1zSLgZbKMGBmSk6Q7Rezj5JYI=
+amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
diff --git a/internal/sheets/estilo.go b/internal/sheets/estilo.go
index 8fe4c17..0c94c92 100644
--- a/internal/sheets/estilo.go
+++ b/internal/sheets/estilo.go
@@ -2,6 +2,7 @@ package sheets
import (
"amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"github.com/go-chi/chi/v5"
)
@@ -12,4 +13,5 @@ import (
func mountEstilo(r chi.Router) {
r.Handle(brand.Prefix+"*", brand.Handler())
r.Handle(theme.FontPrefix+"*", theme.FontHandler())
+ r.Handle(sky.Prefix+"*", sky.Handler())
}
diff --git a/internal/sheets/home.go b/internal/sheets/home.go
index 1d869ff..32cea90 100644
--- a/internal/sheets/home.go
+++ b/internal/sheets/home.go
@@ -1,6 +1,9 @@
package sheets
import (
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/sky"
"context"
"database/sql"
"errors"
@@ -157,10 +160,20 @@ func (a *App) homeLocalComplete(w http.ResponseWriter, r *http.Request) {
a.homeRoutes.LocalComplete(w, r)
}
func (a *App) homeLocalBegin(w http.ResponseWriter, r *http.Request) {
- connect.OAuthContinuation(a.origin, a.handleBegin, func(w http.ResponseWriter, r *http.Request, destination string) {
- p := connect.OAuthPage(destination)
- a.render(w, r, "home_oauth", frame{parts: []string{p.Title}, signinPage: true}, http.StatusOK, p)
- })(w, r)
+ connect.OAuthContinuation(a.origin, a.handleBegin, a.renderHomeOAuth)(w, r)
+}
+
+// renderHomeOAuth is the page between the door and Keymail, and it is
+// the same card: the refresh stays Home's (head), the body is the
+// door's notice.
+func (a *App) renderHomeOAuth(w http.ResponseWriter, r *http.Request, destination string) {
+ p := connect.OAuthPage(destination)
+ a.render(w, r, "home_oauth", frame{parts: []string{p.Title}, signinPage: true}, http.StatusOK, homeOAuthView{
+ Page: p,
+ Door: chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host),
+ Heading: p.Title, Body: "Taking you to Keymail to finish signing in.",
+ Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel}},
+ })
}
func (a *App) rejectDisabledHome(next http.Handler) http.Handler {
@@ -173,3 +186,10 @@ func (a *App) rejectDisabledHome(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
+
+// homeOAuthView is the Keymail continuation page's data: Home's page (for
+// its meta refresh) and the door card around the way on.
+type homeOAuthView struct {
+ Page connect.OAuthData
+ Door chrome.SignInNotice
+}
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index d5dae1a..9470a6e 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -1,6 +1,7 @@
package sheets
import (
+ "amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/home/connect"
"bytes"
"embed"
@@ -73,6 +74,9 @@ func sharedStaticFiles() fs.FS {
files["static/estilo/base.css"] = &fstest.MapFile{Data: base.CSS()}
files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()}
files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()}
+ // The sky behind the sign-in door (estilo sky); its images are
+ // served by sky.Handler.
+ files["static/estilo/sky.css"] = &fstest.MapFile{Data: sky.CSS()}
files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()}
// rastrillo's busy rule (Paul, 2026-09-29): a submit button's label
// gives way to a spinner for at least 650ms, the button disables, and
diff --git a/internal/sheets/signin.go b/internal/sheets/signin.go
index 872f76f..c59e14a 100644
--- a/internal/sheets/signin.go
+++ b/internal/sheets/signin.go
@@ -6,6 +6,7 @@ import (
"amadan.net/oficina/estilo.git/brand"
"amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/sky"
"github.com/carlosframework/rastrillo/sessions"
)
@@ -96,6 +97,8 @@ func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "signin", frame{parts: []string{"Check your email"}, signinPage: true}, http.StatusOK, signinView{
Sent: &chrome.SignInNotice{
Icon: brand.Path("sheets"),
+ App: "Sheets",
+ Sky: sky.For(r.Host),
Heading: "Check your email",
// rastrillo's Begin redirects to ?sent=1 with no address,
// so the page cannot echo it (Docs made the same call).
@@ -108,6 +111,8 @@ func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
text, field := signinError(q.Get("err"))
form := chrome.SignIn{
Icon: brand.Path("sheets"),
+ App: "Sheets",
+ Sky: sky.For(r.Host),
Heading: "Sign in",
Action: "/signin",
AddressLabel: "Email address",
@@ -149,6 +154,8 @@ func (a *App) renderSignedOut(w http.ResponseWriter, r *http.Request, status int
w.Header().Set("Cache-Control", "no-store")
a.render(w, r, "signed_out", frame{parts: []string{"Signed out"}, signinPage: true}, status, chrome.SignInNotice{
Icon: brand.Path("sheets"),
+ App: "Sheets",
+ Sky: sky.For(r.Host),
Heading: "You’re signed out",
Body: "You’ve signed out of Sheets on this device.",
Link: &chrome.Link{Href: "/signin", Label: "Sign in"},
diff --git a/internal/sheets/signin_door_test.go b/internal/sheets/signin_door_test.go
new file mode 100644
index 0000000..37a78d5
--- /dev/null
+++ b/internal/sheets/signin_door_test.go
@@ -0,0 +1,58 @@
+package sheets
+
+import (
+ "html"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// The sign-in page is the Oficina door (estilo chrome): a card on the
+// suite sky with this app's mark and name, its sky layers served signed
+// out by estilo's handler rather than shadowed by the app's /static/.
+func TestSignInPageIsTheDoor(t *testing.T) {
+ app := New(Config{})
+ GET := func(p string) *httptest.ResponseRecorder {
+ return do(t, app, httptest.NewRequest(http.MethodGet, p, nil))
+ }
+ rec := GET("/signin")
+ body := rec.Body.String()
+ for _, want := range []string{"estilo-door", "<span>Sheets</span>", `name="address"`, " autofocus"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("sign-in page lacks %q", want)
+ }
+ }
+ if !regexp.MustCompile(`<img estilo-sky-layer="placeholder"[^>]*src="data:image/png;base64,`).MatchString(body) {
+ t.Error("sign-in page has no inline sky placeholder")
+ }
+ layer := regexp.MustCompile(`<img estilo-sky-layer="[a-z]+"[^>]*src="(/static/estilo/sky/v1/[^"]+)"`).FindStringSubmatch(body)
+ if layer == nil {
+ t.Fatal("sign-in page links no served sky layer")
+ }
+ if got := GET(layer[1]); got.Code != http.StatusOK || got.Header().Get("Content-Type") != "image/svg+xml" {
+ t.Errorf("GET %s signed out: %d %q, want 200 image/svg+xml", layer[1], got.Code, got.Header().Get("Content-Type"))
+ }
+ if !regexp.MustCompile(`/static/estilo/sky\.[0-9a-f]{16}\.css`).MatchString(body) {
+ t.Error("sign-in page does not link sky.css")
+ }
+}
+
+// The page between the door and Keymail is the door's notice, and still
+// carries Home's refresh to the destination.
+func TestKeymailContinuationIsTheDoor(t *testing.T) {
+ app := New(Config{})
+ const dest = "https://keymail.example.test/oauth/authorize?state=s"
+ rec := httptest.NewRecorder()
+ app.renderHomeOAuth(rec, httptest.NewRequest(http.MethodPost, "/signin", nil), dest)
+ body := html.UnescapeString(rec.Body.String())
+ for _, want := range []string{
+ `http-equiv="refresh"`, `content="0;url=` + dest + `"`, `href="` + dest + `"`,
+ "estilo-door", "estilo-sky-layer", "<span>Sheets</span>", "Taking you to Keymail",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("continuation lacks %q", want)
+ }
+ }
+}
diff --git a/internal/sheets/templates/home_oauth.html b/internal/sheets/templates/home_oauth.html
index ad744a9..0e4115a 100644
--- a/internal/sheets/templates/home_oauth.html
+++ b/internal/sheets/templates/home_oauth.html
@@ -1,2 +1,2 @@
-{{define "head"}}{{template "home-oauth-head" .Data}}{{end}}
-{{define "content"}}{{template "home-oauth" .Data}}{{end}}
+{{define "head"}}{{template "home-oauth-head" .Data.Page}}{{end}}
+{{define "content"}}{{template "estilo-signin-sent" .Data.Door}}{{end}}
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
index 6140c74..b988bf2 100644
--- a/internal/sheets/templates/layout.html
+++ b/internal/sheets/templates/layout.html
@@ -39,6 +39,7 @@
<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}">
<link rel="stylesheet" href="{{asset "static/estilo/menu.css"}}">
<link rel="stylesheet" href="{{asset "static/estilo/chrome.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/sky.css"}}">
<link rel="stylesheet" href="{{asset "static/sheets.css"}}">
{{/* First of every script: while a modal dialog is open, key and
clipboard events stop at the window before any page listener hears