oficina / sheets Public

feat: the homepage is the entry page, not a signpost

Signed in, "/" rendered a line naming you and a button reading "Open
your sheets" — a page whose entire content was a link to the next page.

That was correct when it was written and stopped being correct the day
/sheets landed. auth redirects a fresh session to SignedInPath, which
defaults to "/", so every magic-link click arrived here; the page had to
say something, because the first real click landed on a version that
said there was nothing to reach at this address, and a sign-in that
looks identical to a failed one is the bug worth fixing. The button was
that fix. Nobody went back to collapse the hop once there was somewhere
to hop to.

Both halves are closed, and neither alone is enough: auth is now
configured with SignedInPath "/sheets" so a click never comes through
here, and handleIndex redirects the person who later types the bare
address. "/" keeps the anonymous state — the way in, and the callout
about what a live host can read.

Nothing is lost by redirecting, which is what made this safe: the
layout's account menu already carries the address, the sign-out form and
the theme switcher on every signed-in screen, so the landing page names
you at least as clearly as the signpost did.

Two tests used "/" as a stand-in for "a signed-in page" — the affordance
gate and the mode switcher. Both check layout chrome rather than
anything about the root, so both moved to /sheets. The end-to-end
sign-in test keeps its original question, asked of the page you actually
land on: can the person who clicked tell it worked?
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BqAkePcWa7dNbXFr91nwLv
Paul Campbell pushed by paul@keymail.dev e813aa2218b4b5f7025b64918c75306e7cdc7e82 parent 964a8bf
6 files changed, +109 −27
  • internal/sheets/auth.go +7 −2
  • internal/sheets/handlers_table_test.go +4 −2
  • internal/sheets/page.go +25 −10
  • internal/sheets/page_test.go +61 −4
  • internal/sheets/templates/index.html +8 −8
  • internal/sheets/theme_test.go +4 −1

CI passed — run details

diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go
index 63a0477..4d4303c 100644
--- a/internal/sheets/auth.go
+++ b/internal/sheets/auth.go
@@ -151,8 +151,13 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string
Mailer: env.Mailer,
Authorize: authorize,
SigninPath: "/signin",
- Subject: "Your Sheets sign-in link",
- Logger: logger,
+ // Where a fresh session lands. auth defaults this to "/",
+ // which is the anonymous entry page — so without this line
+ // every magic-link click arrives on a page that exists to
+ // offer sign-in, and has to be told it worked.
+ SignedInPath: "/sheets",
+ Subject: "Your Sheets sign-in link",
+ Logger: logger,
})
}
diff --git a/internal/sheets/handlers_table_test.go b/internal/sheets/handlers_table_test.go
index 61d06d6..c6d4dad 100644
--- a/internal/sheets/handlers_table_test.go
+++ b/internal/sheets/handlers_table_test.go
@@ -126,8 +126,10 @@ func TestEveryPageWorksWithoutScript(t *testing.T) {
path string
want []string
}{
- {"/", []string{`action="/signout"`}},
- {"/sheets", []string{`action="/sheets"`, `name="title"`}},
+ // Sign-out is checked on /sheets rather than on "/": it is
+ // layout chrome, present on every signed-in page, and "/"
+ // stopped being one when it started redirecting onward.
+ {"/sheets", []string{`action="/sheets"`, `name="title"`, `action="/signout"`}},
{fmt.Sprintf("/sheets/%d", s.ID), []string{fmt.Sprintf("/sheets/%d/table", s.ID)}},
{fmt.Sprintf("/sheets/%d/table", s.ID), []string{"?page=1&amp;edit=0,0", "Next rows"}},
{fmt.Sprintf("/sheets/%d/table?page=1&edit=0,0", s.ID),
diff --git a/internal/sheets/page.go b/internal/sheets/page.go
index aa018b7..3687ca8 100644
--- a/internal/sheets/page.go
+++ b/internal/sheets/page.go
@@ -2,18 +2,33 @@ package sheets
import "net/http"
-// The root page has two states, and the signed-in one is the whole
-// point: a magic link lands here, so if this page cannot say "you are
-// signed in, as this address", then a successful sign-in and a silently
-// failed one look identical to the person who clicked. That is exactly
-// what happened the first time a link was clicked here — auth's
-// SignedInPath defaults to "/", and this page told the arriving user
-// there was nothing to reach at this address.
+// The root page is the anonymous entry page, and nothing else.
//
-// It was assembled from string constants until the view layer arrived;
-// it is a template now, and the reasoning above is the only part worth
-// keeping from that version.
+// It used to have a signed-in state too, and that state was the whole
+// point of it: a magic link lands here, so if the page could not say
+// "you are signed in, as this address", then a successful sign-in and a
+// silently failed one looked identical to the person who clicked. That
+// is exactly what happened the first time a link was clicked here —
+// auth's SignedInPath defaults to "/", and this page told the arriving
+// user there was nothing to reach at this address.
+//
+// The fix at the time was a line naming you and a button reading "Open
+// your sheets", because /sheets did not exist yet. Once it did, that
+// state was a page whose entire content was a link to the next page,
+// and the layout's account menu names you on every screen anyway. So
+// the signed-in half is a redirect now, and the original requirement is
+// met better: you land on your sheets, which could not be mistaken for
+// a failed sign-in.
+//
+// Both halves of the hop are closed — auth is configured with
+// SignedInPath "/sheets" so a fresh click never comes through here at
+// all, and this redirect covers somebody who later types the bare
+// address. Neither alone is enough.
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if _, ok := a.identity(r); ok {
+ http.Redirect(w, r, "/sheets", http.StatusSeeOther)
+ return
+ }
a.render(w, r, "index", "Oficina Sheets", http.StatusOK, nil)
}
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go
index 17ac33a..c99a5c3 100644
--- a/internal/sheets/page_test.go
+++ b/internal/sheets/page_test.go
@@ -109,17 +109,37 @@ func TestSignInEndToEnd(t *testing.T) {
verify.Code, verify.Header().Get("Location"))
}
- // 4. The root page now says who you are, and offers a way out.
+ // 4. The root page sends you on to your sheets rather than
+ // rendering a signpost, and that page says who you are.
+ //
+ // This assertion used to read the root page itself, because the
+ // root page was the whole proof that a click had worked. It is
+ // the same proof either way: the question is whether the person
+ // who clicked can tell they are signed in, and the page they
+ // actually land on is the one that has to answer it.
after := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range cookies {
req.AddCookie(c)
}
app.Router().ServeHTTP(after, req)
+ if after.Code != http.StatusSeeOther {
+ t.Fatalf("GET / signed in = %d, want 303", after.Code)
+ }
+ if loc := after.Header().Get("Location"); loc != "/sheets" {
+ t.Fatalf("GET / signed in redirects to %q, want /sheets", loc)
+ }
+
+ landing := httptest.NewRecorder()
+ onward := httptest.NewRequest(http.MethodGet, "/sheets", nil)
+ for _, c := range cookies {
+ onward.AddCookie(c)
+ }
+ app.Router().ServeHTTP(landing, onward)
- body := after.Body.String()
- if !strings.Contains(body, "Signed in as") || !strings.Contains(body, "paul@example.com") {
- t.Errorf("root page after sign-in does not name the user:\n%s", body)
+ body := landing.Body.String()
+ if !strings.Contains(body, "paul@example.com") {
+ t.Errorf("page after sign-in does not name the user:\n%s", body)
}
if !strings.Contains(body, `action="/signout"`) {
t.Error("no way to sign out")
@@ -129,6 +149,43 @@ func TestSignInEndToEnd(t *testing.T) {
}
}
+// TestSignedInRootIsNotASignpost: the root page had two states, and the
+// signed-in one was a button reading "Open your sheets" — a page whose
+// entire content for a signed-in person was a link to the next page.
+// That existed because /sheets did not when the root page was written,
+// and auth lands a fresh session on SignedInPath, which defaults to "/".
+// Both halves are fixed: auth lands on /sheets, and anyone who types the
+// bare address is sent there too.
+func TestSignedInRootIsNotASignpost(t *testing.T) {
+ app, cookies := signIn(t)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ app.Router().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("GET / signed in = %d, want 303", rec.Code)
+ }
+ if body := rec.Body.String(); strings.Contains(body, "Open your sheets") {
+ t.Error("root page still renders the signpost")
+ }
+}
+
+// TestAnonymousRootStillOffersAWayIn: the redirect above must not cost
+// the anonymous visitor the only page that offers sign-in.
+func TestAnonymousRootStillOffersAWayIn(t *testing.T) {
+ rec := get(t, "/")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET / anonymous = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), `<a href="/signin">`) {
+ t.Error("anonymous root page offers no way in")
+ }
+}
+
// TestSignInIsSingleUse: the same link must not work twice.
func TestSignInIsSingleUse(t *testing.T) {
app, mail := appWithRecorder(t)
diff --git a/internal/sheets/templates/index.html b/internal/sheets/templates/index.html
index 17a1c9c..13fc274 100644
--- a/internal/sheets/templates/index.html
+++ b/internal/sheets/templates/index.html
@@ -6,16 +6,16 @@
</div>
</header>
-{{if .SignedIn}}
-{{/* The signed-in state is the whole point of this page: a magic link
- lands here, so if it cannot say who you are, a successful sign-in
- and a silently failed one look identical. */}}
-<p>Signed in as <strong>{{.Address}}</strong>.</p>
-<p><a rst-btn="primary" href="/sheets">Open your sheets</a></p>
-{{else}}
+{{/* One state only. This page used to branch on .SignedIn, because a
+ magic link landed here and a page that could not say who you are
+ made a successful sign-in and a silently failed one look identical.
+ That branch was a line naming you and a button to /sheets, written
+ before /sheets existed. handleIndex redirects a signed-in visitor
+ there now and auth lands them there directly, so this page is only
+ ever rendered for somebody who is not signed in — and the layout's
+ account menu is what names you, on every screen. */}}
<p>You are not signed in. <a href="/signin">Sign in</a> — we email you a
link instead of asking for a password.</p>
-{{end}}
<div rst-callout>
<div rst-callout-body>
diff --git a/internal/sheets/theme_test.go b/internal/sheets/theme_test.go
index 9a39ab7..c2c7cdc 100644
--- a/internal/sheets/theme_test.go
+++ b/internal/sheets/theme_test.go
@@ -171,8 +171,11 @@ func TestModeReturnIsNotAnOpenRedirect(t *testing.T) {
// The switcher is chrome, and chrome that needs JavaScript is chrome
// somebody who needs dark mode cannot reach.
func TestModeSwitcherIsAForm(t *testing.T) {
+ // Asked of /sheets, not of "/": the switcher lives in the layout's
+ // account menu, so any signed-in page proves it, and "/" is no
+ // longer one — it redirects a signed-in visitor onward.
app, cookies := signIn(t)
- body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/", nil), cookies)).Body.String()
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)).Body.String()
if !strings.Contains(body, `action="/mode"`) {
t.Fatal("the signed-in page offers no display-mode form")
}