Merge branch 'signin-door' into main
Merge signin-door: sign-in is the Oficina door
9 files changed,
+103
−7
go.mod+1 −1go.sum+4 −0internal/sheets/estilo.go+2 −0internal/sheets/home.go+24 −4internal/sheets/render.go+4 −0internal/sheets/signin.go+7 −0internal/sheets/signin_door_test.go+58 −0internal/sheets/templates/home_oauth.html+2 −2internal/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 |