Merge origin/main (f5a1254, door copy) into feat/r2-shared-chrome; estilo main 530cb93, connect 2bec942
The door's words now come from estilo's one copy set: chrome.SignIn loses Heading, AddressLabel, Help and Submit; the sent state is SignInNotice.Sent(a.signinAgain(r, "")) so "Use a different address" keeps return_to; titles take .Page() under the suffixing layout; the signed-out page is .SignedOut(href), standalone and with Home. Main's email subject (Sign in to Calls) is kept. Astra's batch nits: the Home signed-out test asserts the retired "Sign in with Home" rather than connect.English's action, which Home #79 made "Sign in"; and the account link is "Connect your Oficina account" in the account menu (TestTheAccountMenuOffersToConnectYour- OficinaAccount, red on main f5a1254).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+126
−59
go.mod+2 −2go.sum+4 −6internal/calls/auth.go+24 −40internal/calls/home.go+3 −2internal/calls/home_router_test.go+29 −2internal/calls/templates/signedout.html+3 −3internal/callstest/harness_test.go+15 −3internal/callstest/signin_test.go+46 −1
CI passed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 go: downloading amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore (cached) ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.515s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 8.748s ? amadan.net/oficina/calls/protocol [no test files] ? amadan.net/oficina/calls/scripts/writeestilo [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 10.964s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 26.847s
diff --git a/go.mod b/go.mod| index 9f8c78d..1cd97b6 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,8 +3,8 @@ module amadan.net/oficina/calls |
| go 1.26.0 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20261001081853-759a8ff71c5a |
| - amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7 |
| + amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 |
| + amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/go-chi/chi/v5 v5.3.2 |
diff --git a/go.sum b/go.sum| index 0f8bbb5..da3dcea 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,9 +1,7 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20261001081853-759a8ff71c5a h1:7eP/rgQjU0KAtYbhiaFSplK5VF2s49NeUKjki/li3zg= |
| -amadan.net/oficina/estilo.git v0.0.0-20261001081853-759a8ff71c5a/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7 h1:FVzfiViGRUtl/enMtf34LL2ECLCLJx5V2JJmsOxuOEI= |
| -amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 h1:bVscoa9fH5LdLDjlyecZhrX7JzWPT0blb57MH0FIm2U= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c h1:3yxO2D8BxDLMGfFV2o6Sp9Z91yLnjDCIuRKHVWORa8A= |
| +amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
diff --git a/internal/calls/auth.go b/internal/calls/auth.go| index ddfdea7..8a29ba1 100644 |
| --- a/internal/calls/auth.go |
| +++ b/internal/calls/auth.go |
| @@ -33,6 +33,7 @@ func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) { |
| Origin: a.cfg.Origin, |
| InstanceKey: a.cfg.instanceKey, |
| Mailer: a.cfg.mailer, |
| + Subject: chrome.EmailSubject("Calls"), |
| Authorize: a.authorize, |
| SigninPath: signinPath, |
| SignedInPath: landing, |
| @@ -241,34 +242,24 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| } |
| w.Header().Set("Cache-Control", "no-store") |
| if q.Get("sent") == "1" { |
| - render(w, "signin", a.page(r, frame{parts: []string{"Check your email"}, signinPage: true}, map[string]any{ |
| - "Sent": true, |
| - "Notice": chrome.SignInNotice{ |
| - Icon: assets.Path("static/brand/icon.svg"), |
| - App: appName, |
| - Sky: sky.For(r.Host), |
| - Heading: "Check your email", |
| - // rastrillo's Begin redirects to ?sent=1 with no address, so |
| - // this cannot echo it, as Docs, Sheets and Slides cannot. |
| - Body: "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| - // Back to the form, keeping the return (spec §9.2): the |
| - // remembered page, not a bare /signin, which would forget it |
| - // (Astra on Docs #65, finding 2). |
| - Link: &chrome.Link{Href: a.signinAgain(r, ""), Label: "Use a different address"}, |
| - }, |
| - })) |
| + // The door's own check-your-email state (estilo DoorCopy). "Use a |
| + // different address" goes back to the form keeping the return |
| + // (spec §9.2): the remembered page, not a bare /signin, which would |
| + // forget it (Astra on Docs #65, finding 2). rastrillo's Begin |
| + // redirects to ?sent=1 with no address, so the body cannot echo it. |
| + notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.Sent(a.signinAgain(r, "")) |
| + render(w, "signin", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Sent": true, "Notice": notice})) |
| return |
| } |
| + // The door's words (heading, label, help, the "Sign in" submit) are |
| + // estilo's one copy set; Calls passes its name, mark, sky and |
| + // destinations. |
| door := chrome.SignIn{ |
| - Icon: assets.Path("static/brand/icon.svg"), |
| - App: appName, |
| - Sky: sky.For(r.Host), |
| - Heading: "Sign in", |
| - Action: signinPath, |
| - AddressLabel: "Email address", |
| - Help: "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| - Submit: "Sign in", |
| - OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: appName, |
| + Sky: sky.For(r.Host), |
| + Action: signinPath, |
| + OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| } |
| if hasReturn { |
| door.Return = ret |
| @@ -287,7 +278,7 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| default: |
| door.Error = "We couldn’t sign you in. Enter your email to get a new link." |
| } |
| - render(w, "signin", a.page(r, frame{parts: []string{"Sign in"}, signinPage: true}, map[string]any{"Door": door})) |
| + render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door})) |
| } |
| // signinAgain is the form's own address, keeping whatever page the |
| @@ -374,23 +365,16 @@ func (a *app) welcome(w http.ResponseWriter, r *http.Request) { |
| // signedOut serves GET /signed-out standalone: where Sign out lands. |
| // With Home configured connect serves it, through renderHome. |
| func (a *app) signedOut(w http.ResponseWriter, r *http.Request) { |
| - w.Header().Set("Cache-Control", "no-store") |
| - a.renderSignedOut(w, r, "You’re signed out", "You’ve signed out of Calls on this device.", signinPath, "Sign in", http.StatusOK) |
| + a.renderSignedOut(w, r, signinPath, http.StatusOK) |
| } |
| -// renderSignedOut is the signed-out page, the Oficina door's notice with |
| -// a primary Sign in: standalone in the suite's words (spec §9.2), with |
| -// Home in connect's. |
| -func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, heading, body, href, label string, status int) { |
| +// renderSignedOut is the signed-out page: the door's own notice |
| +// (estilo's SignedOut, the same words in every app), whose primary Sign in |
| +// goes to signin — /signin standalone, connect's action with Home. |
| +func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, signin string, status int) { |
| + notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.SignedOut(signin) |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-store") |
| w.WriteHeader(status) |
| - render(w, "signedout", a.page(r, frame{parts: []string{"Signed out"}, signinPage: true}, map[string]any{"Notice": chrome.SignInNotice{ |
| - Icon: assets.Path("static/brand/icon.svg"), |
| - App: appName, |
| - Sky: sky.For(r.Host), |
| - Heading: heading, |
| - Body: body, |
| - Link: &chrome.Link{Href: href, Label: label}, |
| - }})) |
| + render(w, "signedout", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Notice": notice})) |
| } |
diff --git a/internal/calls/home.go b/internal/calls/home.go| index 2501a5f..38cf510 100644 |
| --- a/internal/calls/home.go |
| +++ b/internal/calls/home.go |
| @@ -163,8 +163,9 @@ func (a *app) endRemovedSession(w http.ResponseWriter, r *http.Request) { |
| func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| home := connect.Page(state, label) |
| if state == "signedout" { |
| - // Where Sign out lands: the Oficina door, in connect's own words. |
| - a.renderSignedOut(w, r, home.Title, home.Body, home.Action, home.ActionLabel, status) |
| + // Where Sign out lands: the Oficina door, in the door's words |
| + // (connect's name the hub); its Sign in goes to connect's action. |
| + a.renderSignedOut(w, r, home.Action, status) |
| return |
| } |
| var body bytes.Buffer |
diff --git a/internal/calls/home_router_test.go b/internal/calls/home_router_test.go| index e1feb68..c1eb08f 100644 |
| --- a/internal/calls/home_router_test.go |
| +++ b/internal/calls/home_router_test.go |
| @@ -355,7 +355,8 @@ func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) { |
| } |
| // With Home connected, Sign out lands on the Oficina door's signed-out |
| -// page, in connect's own words, and its one link goes back to /signin. |
| +// page, in the door's own words (not connect's, which name the hub), and |
| +// its one link, "Sign in", goes back to connect's sign-in action. |
| func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| w := httptest.NewRecorder() |
| @@ -364,11 +365,21 @@ func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) { |
| if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" { |
| t.Fatalf("GET /signed-out: %d %q", w.Code, w.Header().Get("Cache-Control")) |
| } |
| - for _, want := range []string{"<div estilo-door", "<span>Calls</span>", "estilo-sky-layer", connect.English["signedout_title"], `<a rst-btn="primary" href="/signin">` + connect.English["signedout_action"]} { |
| + for _, want := range []string{"<div estilo-door", "<span>Calls</span>", "estilo-sky-layer", |
| + "<title>Signed out · Calls</title>", "<h1>You’re signed out</h1>", "You’ve signed out of Calls on this device.", |
| + `<a rst-btn="primary" href="/signin">Sign in</a>`} { |
| if !strings.Contains(body, want) { |
| t.Errorf("signed-out page is missing %q:\n%s", want, body) |
| } |
| } |
| + // "Sign in with Home" is the retired label: connect.English now says |
| + // "Sign in" itself (Home #79), so asserting its absence by key would |
| + // contradict the "Sign in" button required above. |
| + for _, gone := range []string{connect.English["signedout_body"], "Sign in with Home", "Home"} { |
| + if strings.Contains(body, gone) { |
| + t.Errorf("signed-out page still carries %q:\n%s", gone, body) |
| + } |
| + } |
| } |
| // The revoked-user loop Astra found in Docs (#65, 1) and Sheets (#39, 2): |
| @@ -388,3 +399,19 @@ func TestARemovedHomeMemberIsNotBouncedBetweenSigninAndThePage(t *testing.T) { |
| t.Fatalf("after the notice, /signin did not start a sign-in: %q", next) |
| } |
| } |
| + |
| +// The account link is "Connect your Oficina account" (spec §13 Q3: the |
| +// hub is never named), in the account menu, offered to a local session |
| +// only: "Connect Home account" on the list page is retired. |
| +func TestTheAccountMenuOffersToConnectYourOficinaAccount(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + body := f.do("GET", "/", "").Body.String() |
| + if !strings.Contains(body, `<a estilo-menu-item href="/account/home"><span estilo-menu-label>Connect your Oficina account</span></a>`) || strings.Contains(body, "Connect Home account") { |
| + t.Fatalf("the list page's account link:\n%s", body) |
| + } |
| + f.link(t) |
| + if body := f.do("GET", "/", "").Body.String(); strings.Contains(body, "/account/home") { |
| + t.Fatal("a Home session is offered to connect the account it already is") |
| + } |
| +} |
diff --git a/internal/calls/templates/signedout.html b/internal/calls/templates/signedout.html| index 7ab02a1..abad39f 100644 |
| --- a/internal/calls/templates/signedout.html |
| +++ b/internal/calls/templates/signedout.html |
| @@ -1,4 +1,4 @@ |
| -{{/* Where Sign out lands with Home connected: the Oficina door's |
| - signed-out page. home.go's renderHome builds it from connect's own |
| - signed-out strings. */}} |
| +{{/* Where Sign out lands: the Oficina door's signed-out page, in the |
| + door's own words. auth.go's renderSignedOut builds it, standalone |
| + and with Home. */}} |
| {{define "content"}}{{template "estilo-signed-out" .Notice}}{{end}} |
diff --git a/internal/callstest/harness_test.go b/internal/callstest/harness_test.go| index 22305b1..fc4aedf 100644 |
| --- a/internal/callstest/harness_test.go |
| +++ b/internal/callstest/harness_test.go |
| @@ -159,9 +159,10 @@ func (x *harness) pendingJoins(t *testing.T) int { |
| } |
| type recordingMailer struct { |
| - mu sync.Mutex |
| - to []string |
| - links []string |
| + mu sync.Mutex |
| + to []string |
| + subjects []string |
| + links []string |
| } |
| var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| @@ -170,6 +171,7 @@ func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) er |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| m.to = append(m.to, to) |
| + m.subjects = append(m.subjects, subject) |
| m.links = append(m.links, magicLink.FindString(body)) |
| return nil |
| } |
| @@ -183,6 +185,16 @@ func (m *recordingMailer) last() (string, string) { |
| return m.to[len(m.to)-1], m.links[len(m.links)-1] |
| } |
| +func (m *recordingMailer) lastSubject(t *testing.T) string { |
| + t.Helper() |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if len(m.subjects) == 0 { |
| + t.Fatal("no mail sent") |
| + } |
| + return m.subjects[len(m.subjects)-1] |
| +} |
| + |
| func (m *recordingMailer) count() int { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
diff --git a/internal/callstest/signin_test.go b/internal/callstest/signin_test.go| index 24f5808..9d67806 100644 |
| --- a/internal/callstest/signin_test.go |
| +++ b/internal/callstest/signin_test.go |
| @@ -160,7 +160,7 @@ func TestSigninPageIsTheOficinaDoor(t *testing.T) { |
| body := rec.Body.String() |
| for _, want := range []string{ |
| "<div estilo-door", "<span>Calls</span>", `<img estilo-sky-layer="placeholder"`, |
| - `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`, `type="submit">Sign in</button>`, |
| + `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`, |
| } { |
| if !strings.Contains(body, want) { |
| t.Errorf("sign-in page is missing %q:\n%s", want, body) |
| @@ -196,3 +196,48 @@ func TestSigninPageIsTheOficinaDoor(t *testing.T) { |
| t.Errorf("err=rate is not a page error:\n%s", page) |
| } |
| } |
| + |
| +// The door's words are estilo's, the same in every Oficina app: the |
| +// submit reads "Sign in" (never "Email me a link", Paul 2026-10-01), one |
| +// help line says what happens, the sent state says the one body and links |
| +// back with "Use a different address", and each tab title is |
| +// "<Page> · Calls". |
| +func TestSigninDoorSpeaksTheSuitesCopy(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + form := c.get("/signin").Body.String() |
| + for _, want := range []string{ |
| + "<title>Sign in · Calls</title>", "<h1>Sign in</h1>", ">Email address<", |
| + "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| + `<button rst-btn="primary" type="submit">Sign in</button>`, |
| + } { |
| + if !strings.Contains(form, want) { |
| + t.Errorf("sign-in form is missing %q:\n%s", want, form) |
| + } |
| + } |
| + if strings.Contains(form, "Email me a link") { |
| + t.Errorf("sign-in form still says \"Email me a link\":\n%s", form) |
| + } |
| + sent := c.get("/signin?sent=1").Body.String() |
| + for _, want := range []string{ |
| + "<title>Check your email · Calls</title>", "<h1>Check your email</h1>", |
| + "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| + `<a href="/signin">Use a different address</a>`, |
| + } { |
| + if !strings.Contains(sent, want) { |
| + t.Errorf("sent page is missing %q:\n%s", want, sent) |
| + } |
| + } |
| +} |
| + |
| +// The sign-in email's subject names the app: "Sign in to Calls". |
| +func TestSigninEmailSubjectNamesCalls(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}}) |
| + if x.mail.count() != 1 { |
| + t.Fatalf("sent %d mails, want 1", x.mail.count()) |
| + } |
| + if got := x.mail.lastSubject(t); got != "Sign in to Calls" { |
| + t.Errorf("sign-in email subject %q, want %q", got, "Sign in to Calls") |
| + } |
| +} |