oficina / calendar Public

Standalone sign-out lands on the signed-out page

Without Home, /signed-out was a 404 and Sign out went back to the sign-in
form, as if nothing had happened. Now /signed-out serves the same estilo
signed-out door Home mode lands on, in Calendar's words ("Signed out",
"You’re signed out of Calendar on this browser.", Sign in), and the
standalone POST /signout lands there: rastrillo's Signout still revokes
the session; only its final 303 is retargeted from /signin, which every
other auth outcome also uses.

TestStandaloneSignOutLandsOnTheSignedOutPage failed before (303 to
/signin). Fable 5.1 follow-up finding 4 on #17.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a8ce9de35716933564d24e5179c86db2bb571b9b parent defbb39
3 files changed, +75 −1
  • internal/calendar/routes.go +2 −1
  • internal/calendar/signin.go +29 −0
  • internal/calendartest/signed_out_test.go +44 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendar	4.614s
?   	amadan.net/oficina/calendar/internal/calendar/icons	[no test files]
?   	amadan.net/oficina/calendar/internal/calendar/seal	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendartest	2.360s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.045s
?   	amadan.net/oficina/calendar/scripts/writeestilo	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go
index 84c9c31..a5162ee 100644
--- a/internal/calendar/routes.go
+++ b/internal/calendar/routes.go
@@ -56,6 +56,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
r.With(a.home.sessions.Require).Post("/account/home/confirm", a.home.routes.ConfirmPost)
} else {
r.Get("/signin", h.signin)
+ r.Get("/signed-out", h.signedOut)
}
// One counter for the life of the router, so the log line can say
// how many refusals this boot has seen rather than only that one
@@ -76,7 +77,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
r.Post("/signin", begin)
r.Get("/auth/callback", authPlugin.Callback)
r.Get("/auth/verify", authPlugin.Verify)
- r.Post("/signout", authPlugin.Signout)
+ r.Post("/signout", signOutToSignedOut(authPlugin.Signout))
}
// The light/dark switcher, outside the session on purpose: the
diff --git a/internal/calendar/signin.go b/internal/calendar/signin.go
index c34437c..1a2a350 100644
--- a/internal/calendar/signin.go
+++ b/internal/calendar/signin.go
@@ -95,3 +95,32 @@ func signedOutDoor(r *http.Request, heading, body string, link chrome.Link) sign
Link: &link,
}}
}
+
+// signedOut serves GET /signed-out without Home: the same estilo door Home
+// mode's sign-out lands on, in Calendar's own words, so signing out is a
+// page that says so rather than the sign-in form again (or, before, a
+// 404 for anyone who reached the address).
+func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ render(w, "signin", h.page(r, "Signed out", signedOutDoor(r, "Signed out",
+ "You’re signed out of Calendar on this browser.", chrome.Link{Href: "/signin", Label: "Sign in"})))
+}
+
+// signOutToSignedOut is rastrillo's standalone Sign out with one change:
+// where it lands. auth.Signout revokes the session and answers 303 to its
+// SigninPath, which every other auth outcome also uses, so the path cannot
+// change; this retargets that one redirect to /signed-out.
+func signOutToSignedOut(signout http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ signout(&signedOutRedirect{ResponseWriter: w}, r)
+ }
+}
+
+type signedOutRedirect struct{ http.ResponseWriter }
+
+func (s *signedOutRedirect) WriteHeader(code int) {
+ if code == http.StatusSeeOther && s.Header().Get("Location") == "/signin" {
+ s.Header().Set("Location", "/signed-out")
+ }
+ s.ResponseWriter.WriteHeader(code)
+}
diff --git a/internal/calendartest/signed_out_test.go b/internal/calendartest/signed_out_test.go
new file mode 100644
index 0000000..ae50df1
--- /dev/null
+++ b/internal/calendartest/signed_out_test.go
@@ -0,0 +1,44 @@
+package calendartest
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// Standalone (no Home), signing out lands on a real page: the estilo
+// signed-out door at /signed-out, as Home mode's sign-out does, rather than
+// back on the sign-in form as if nothing happened. /signed-out used to 404
+// without Home (Fable 5.1 follow-up finding 4 on #17).
+func TestStandaloneSignOutLandsOnTheSignedOutPage(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ rec := postAs(t, h, session, "/signout", nil)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" {
+ t.Fatalf("POST /signout: %d to %q, want 303 to /signed-out", rec.Code, rec.Header().Get("Location"))
+ }
+ if after := getAs(t, h, session, "/day/2026-09-24"); after.Code != http.StatusSeeOther {
+ t.Fatalf("the session survived sign-out: GET a view answered %d", after.Code)
+ }
+
+ page := get(t, h, "/signed-out")
+ body := page.Body.String()
+ if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") {
+ t.Fatalf("GET /signed-out: %d %s", page.Code, page.Header().Get("Content-Type"))
+ }
+ for _, want := range []string{
+ "<title>Signed out · Calendar</title>",
+ "estilo-door",
+ "<h1>Signed out</h1>",
+ `<a rst-btn="primary" href="/signin">Sign in</a>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("/signed-out is missing %q:\n%s", want, body)
+ }
+ }
+ // The page that IS signing out shows nothing in the bar's corner.
+ if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") {
+ t.Error("/signed-out drew a Sign in button or an account menu in the bar")
+ }
+}