Integrate Home sign-in with existing Slides identities
16 files changed,
+725
−29
cmd/slides/main.go+7 −1go.mod+3 −1go.sum+6 −2internal/slides/app.go+46 −10internal/slides/auth.go+26 −7internal/slides/config.go+2 −0internal/slides/decks.go+2 −0internal/slides/exports.go+7 −3internal/slides/handlers.go+9 −4internal/slides/home.go+151 −0internal/slides/home_test.go+411 −0internal/slides/locales/en.toml+38 −0internal/slides/presentation.go+9 −0internal/slides/render.go+5 −1internal/slides/templates/account.html+1 −0internal/slides/templates/home_link.html+2 −0
CI passed — run details
diff --git a/cmd/slides/main.go b/cmd/slides/main.go| index 09f4dfe..a832ee4 100644 |
| --- a/cmd/slides/main.go |
| +++ b/cmd/slides/main.go |
| @@ -3,6 +3,7 @@ |
| package main |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "fmt" |
| "log/slog" |
| "os" |
| @@ -222,7 +223,12 @@ func main() { |
| // Every empty value below is a startup refusal inside slides.New |
| // unless env is "development" — see config.go's package comment. |
| - mux, a, err := slides.New(d, slides.Config{ |
| + var homeConfig *connect.Credentials |
| + hc := connect.Credentials{Issuer: os.Getenv("SLIDES_HOME_ISSUER"), ConsumerID: os.Getenv("SLIDES_HOME_CONSUMER_ID"), KeysBase64: os.Getenv("SLIDES_HOME_KEYS_BASE64"), DEKBase64: os.Getenv("SLIDES_HOME_DEK_BASE64")} |
| + if hc.Issuer != "" || hc.ConsumerID != "" || hc.KeysBase64 != "" || hc.DEKBase64 != "" { |
| + homeConfig = &hc |
| + } |
| + mux, a, err := slides.New(d, slides.Config{Home: homeConfig, |
| Origin: origin, |
| Env: env, |
| InstanceKey: os.Getenv("SLIDES_INSTANCE_KEY"), |
diff --git a/go.mod b/go.mod| index 05e4cf6..8f3df20 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,7 +3,8 @@ module amadan.net/oficina/slides |
| go 1.25.0 |
| require ( |
| - amadan.net/rastrillo/rastrillo v0.26.0 |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| @@ -14,6 +15,7 @@ require ( |
| ) |
| require ( |
| + amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f // indirect |
| github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/chromedp/sysutil v1.1.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
diff --git a/go.sum b/go.sum| index 44caa20..646882f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,9 @@ |
| -amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| -amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE= |
| +amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo= |
| +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= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index 92d0dd1..b2cda4b 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -1,6 +1,8 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/handoff" |
| "context" |
| "net/http" |
| @@ -49,19 +51,40 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| // is born covered. Origin-checking, not tokens — nothing to mint |
| // or forget in a form. a.cfg.Origin, not the caller's cfg.Origin: |
| // what this protects has to be the origin the app resolved. |
| - r.Use(csrf.Protect(a.cfg.Origin)) |
| + if a.homeRoutes != nil { |
| + r.Use(a.homeRoutes.Middleware) |
| + r.Use(a.homeMenu) |
| + } else { |
| + r.Use(csrf.Protect(a.cfg.Origin)) |
| + } |
| // Unauthenticated: the front door and rauth's four handlers. The |
| // sign-in page has to be reachable signed out or nobody could ever |
| // get in. |
| - r.Get(signinPath, a.signin) |
| - // gateBegin, not authPlugin.Begin directly — see its own doc |
| - // comment: the allowlist has to sit in FRONT of Begin, because |
| - // Begin is what sends the mail. |
| - r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger)) |
| - r.Get("/auth/callback", authPlugin.Callback) |
| - r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + if a.homeRoutes == nil { |
| + r.Get(signinPath, a.signin) |
| + r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger)) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + } else { |
| + h := a.homeRoutes |
| + r.Get(signinPath, h.Signin) |
| + r.Get("/signin/local", a.signin) |
| + r.Post(signinPath, h.LocalGuard(gateBegin(authPlugin.Begin, a.allowed, a.logger))) |
| + r.Get("/auth/callback", h.LocalGuard(authPlugin.Callback)) |
| + r.Get("/auth/verify", h.LocalGuard(authPlugin.Verify)) |
| + r.Post("/signout", h.Signout) |
| + r.Get("/signed-out", h.SignedOut) |
| + r.Get(connect.LocalCompletePath, h.LocalComplete) |
| + r.Get(handoff.StartPath, a.home.Start) |
| + r.Post(handoff.CallbackPath, a.home.Callback) |
| + r.Get(handoff.FinishPath, a.home.Finish) |
| + r.Get("/account/home", h.Account) |
| + r.Post("/account/home", h.LinkStart) |
| + r.Get("/account/home/confirm", a.home.ConfirmPage) |
| + r.Post("/account/home/confirm", h.ConfirmPost) |
| + } |
| // The share link, and the one screen this app serves to somebody |
| // with no session at all (spec §5). Outside the group below on |
| @@ -234,7 +257,15 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| // while Schema (migrations.go) stays just this app's own so the |
| // generator and its check never see a subsystem's tables and try |
| // to drop them. |
| - migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| + homeConfig, err := connect.DecodeCredentials(cfg.Home, cfg.Origin) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + boot := BootSchema |
| + if homeConfig != nil { |
| + boot = migrate.Merge(BootSchema, homeSchema) |
| + } |
| + migrated, err := migrate.Apply(context.Background(), d, boot) |
| if err != nil { |
| return nil, nil, err |
| } |
| @@ -276,6 +307,11 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| pdf: &pdfRuntime{}, |
| } |
| + if err := a.initHome(homeConfig); err != nil { |
| + rt.Close(context.Background()) |
| + return nil, nil, err |
| + } |
| + |
| // The export's status page, its polled fragment and its SSE stream |
| // (exports.go). rastrillo/jobs owns the lifecycle and the |
| // owner-only lookup; the two renderers are this app's, so the page |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| index 4a2e3fa..b018d5e 100644 |
| --- a/internal/slides/auth.go |
| +++ b/internal/slides/auth.go |
| @@ -18,6 +18,7 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "context" |
| "database/sql" |
| "errors" |
| @@ -258,14 +259,19 @@ func (s *sameAnswer) WriteHeader(status int) { |
| // once, out loud, is what keeps the two from drifting silently if |
| // either default ever changes. |
| func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist) (*rauth.Auth, error) { |
| + signedInPath := "/" |
| + if cfg.Home != nil { |
| + signedInPath = connect.LocalCompletePath |
| + } |
| return rauth.New(rauth.Config{ |
| - DB: writer, |
| - Origin: cfg.Origin, |
| - InstanceKey: cfg.InstanceKey, |
| - Mailer: cfg.Mailer, |
| - Authorize: list.allows, |
| - SigninPath: signinPath, |
| - Logger: cfg.Logger, |
| + SignedInPath: signedInPath, |
| + DB: writer, |
| + Origin: cfg.Origin, |
| + InstanceKey: cfg.InstanceKey, |
| + Mailer: cfg.Mailer, |
| + Authorize: list.allows, |
| + SigninPath: signinPath, |
| + Logger: cfg.Logger, |
| }) |
| } |
| @@ -375,6 +381,14 @@ func (a *app) currentUser(w http.ResponseWriter, r *http.Request) (User, session |
| if !ok || s.Subject == "" { |
| return User{}, sessionNone |
| } |
| + if s.Method == connect.HomeMethod { |
| + u, err := a.homeUser(r, s) |
| + if err != nil { |
| + a.revokeHome(w, r, s.Subject) |
| + return User{}, sessionOffboarded |
| + } |
| + return u, sessionOK |
| + } |
| if !a.allowed.allows(s.Subject) { |
| a.offboard(w, r, s.Subject) |
| return User{}, sessionOffboarded |
| @@ -445,6 +459,11 @@ const ( |
| // for a fetch or a form POST. |
| func (a *app) offboard(w http.ResponseWriter, r *http.Request, address string) { |
| email := normalizeAddress(address) |
| + if a.home != nil { |
| + if err := a.db.G.WithContext(r.Context()).Exec("DELETE FROM sessions WHERE method = ? AND subject IN (SELECT l.subject FROM home_identity_links l JOIN users u ON u.id=l.person_id WHERE u.email=?)", connect.HomeMethod, email).Error; err != nil { |
| + a.logger.Error("slides: revoke linked Home sessions", "err", err) |
| + } |
| + } |
| a.logger.Warn("slides: a signed-in address is no longer admitted; ending its sessions", |
| "address", email) |
diff --git a/internal/slides/config.go b/internal/slides/config.go| index b71e399..867da56 100644 |
| --- a/internal/slides/config.go |
| +++ b/internal/slides/config.go |
| @@ -13,6 +13,7 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "fmt" |
| "log/slog" |
| "net/url" |
| @@ -40,6 +41,7 @@ const DefaultOrigin = "http://localhost:8091" |
| // Config is what New needs to build the app. cmd/slides/main.go fills |
| // it from the environment; the test harness fills it directly. |
| type Config struct { |
| + Home *connect.Credentials |
| // Origin is this instance's external origin, scheme included — |
| // "https://slides.oficina.works". It is three things at once: |
| // csrf.Protect's yardstick for the legacy Origin/Referer fallback |
diff --git a/internal/slides/decks.go b/internal/slides/decks.go| index 47fb5b4..156ada2 100644 |
| --- a/internal/slides/decks.go |
| +++ b/internal/slides/decks.go |
| @@ -527,6 +527,7 @@ func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck |
| // struct, so the menu is one partial rather than a block redefined in |
| // every template. |
| type shell struct { |
| + HomeLabel string |
| Person string |
| AgentsLabel string |
| SignoutLabel string |
| @@ -539,6 +540,7 @@ func shellFor(r *http.Request, u User) shell { |
| } |
| return shell{ |
| Person: person, |
| + HomeLabel: homeMenuLabel(r), |
| AgentsLabel: rastrillo.T(r, "slides.account.agents"), |
| SignoutLabel: rastrillo.T(r, "slides.account.signout"), |
| } |
diff --git a/internal/slides/exports.go b/internal/slides/exports.go| index 583a704..472fb06 100644 |
| --- a/internal/slides/exports.go |
| +++ b/internal/slides/exports.go |
| @@ -596,7 +596,7 @@ func (a *app) exportJobRoute(w http.ResponseWriter, r *http.Request) { |
| a.renderExportPage(w, r, d) |
| return |
| } |
| - a.jobPages.StatusPage(w, r) |
| + a.jobPages.StatusPage(w, a.exportPrincipal(r)) |
| } |
| // exportFragment and exportEvents are the polled twins of the page, |
| @@ -609,7 +609,7 @@ func (a *app) exportFragment(w http.ResponseWriter, r *http.Request) { |
| a.renderExportFragment(w, r, d) |
| return |
| } |
| - a.jobPages.Fragment(w, r) |
| + a.jobPages.Fragment(w, a.exportPrincipal(r)) |
| } |
| // exportFallback is what the status page and its fragment answer with |
| @@ -639,6 +639,7 @@ func (a *app) exportFragment(w http.ResponseWriter, r *http.Request) { |
| // missing one is jobs.StatusPage's 403 to write, not a 500 here on top |
| // of it. |
| func (a *app) exportFallback(r *http.Request) (jobs.PageData, bool) { |
| + r = a.exportPrincipal(r) |
| s, ok := sessions.Current(r) |
| if !ok || s.Subject == "" { |
| return jobs.PageData{}, false |
| @@ -669,7 +670,7 @@ func (a *app) exportEvents(w http.ResponseWriter, r *http.Request) { |
| if !a.exportJobIsHere(w, r) { |
| return |
| } |
| - a.jobPages.Events(w, r) |
| + a.jobPages.Events(w, a.exportPrincipal(r)) |
| } |
| // exportJobIsHere is the deck check rastrillo/jobs cannot make: it |
| @@ -892,6 +893,7 @@ type exportStatus struct { |
| // alternatives rather than a wrapper around a partial: the shim keeps |
| // the first element of what it fetches and drops the rest. |
| func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.PageData) { |
| + r = restoreExportPrincipal(r) |
| u, ok := a.viewer(w, r) |
| if !ok { |
| return |
| @@ -902,6 +904,7 @@ func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.Pa |
| } |
| func (a *app) renderExportFragment(w http.ResponseWriter, r *http.Request, d jobs.PageData) { |
| + r = restoreExportPrincipal(r) |
| renderFragment(w, "export_status", "export-body", a.exportStatusFor(r, d)) |
| } |
| @@ -1134,6 +1137,7 @@ func (a *app) apiExportStatus(w http.ResponseWriter, r *http.Request) { |
| // about sessions.UserID): jobs.Get answers the owner and nobody else, |
| // and the owner it compares against is this string. |
| func (a *app) sessionSubject(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + r = a.exportPrincipal(r) |
| s, ok := sessions.Current(r) |
| if !ok || s.Subject == "" { |
| a.fail(w, r, "no session subject past the session gate", nil) |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index 728173f..b499397 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -1,6 +1,8 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "context" |
| "log/slog" |
| @@ -23,10 +25,13 @@ import ( |
| // The handlers themselves live beside what they serve: sign-in in |
| // auth.go, the deck screens in decks.go. |
| type app struct { |
| - db *db.DB |
| - logger *slog.Logger |
| - cfg Config |
| - allowed allowlist |
| + home *connect.Bridge |
| + homeRoutes *connect.Routes |
| + homeSessions *sessions.Sessions |
| + db *db.DB |
| + logger *slog.Logger |
| + cfg Config |
| + allowed allowlist |
| // core is the compiled deck core, shared by every request. |
| // Compiling the wasm module is expensive and instantiating it is |
| // not, so there is exactly one of these per process and a Handle |
diff --git a/internal/slides/home.go b/internal/slides/home.go| new file mode 100644 |
| index 0000000..a3ef86f |
| --- /dev/null |
| +++ b/internal/slides/home.go |
| @@ -0,0 +1,151 @@ |
| +package slides |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, "home_connect")) |
| +var errHomeIdentity = errors.New("slides: Home identity refused") |
| + |
| +func neutralSession(s sessions.Session) connect.Session { |
| + return connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At} |
| +} |
| + |
| +func (a *app) initHome(c *connect.DecodedCredentials) error { |
| + if c == nil { |
| + return nil |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Logger: a.logger}) |
| + if err != nil { |
| + return err |
| + } |
| + a.homeSessions = sess |
| + read := func(r *http.Request) (*connect.Session, error) { |
| + s, ok := sess.From(r) |
| + if !ok { |
| + return nil, nil |
| + } |
| + n := neutralSession(s) |
| + return &n, nil |
| + } |
| + b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: c.Issuer, ConsumerID: c.ConsumerID, Namespace: "oficina/slides", SessionCookie: sess.CookieName(), Keys: c.Keys, DEK: c.DEK, ReadSession: read, |
| + MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| + return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| + }, |
| + LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| + if s.Method != "magiclink" && s.Method != "keymail" { |
| + return connect.Person{}, errHomeIdentity |
| + } |
| + var u User |
| + if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) { |
| + return connect.Person{}, errHomeIdentity |
| + } |
| + return connect.Person{ID: u.ID, Label: u.Email}, nil |
| + }, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + var email string |
| + if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil || !a.allowed.allows(email) { |
| + return "", errHomeIdentity |
| + } |
| + return email, nil |
| + }, Render: a.renderHome}) |
| + if err != nil { |
| + return err |
| + } |
| + a.home = b |
| + a.homeRoutes, err = connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/slides", Origin: a.cfg.Origin, Issuer: c.Issuer, DEK: c.DEK, SessionCookie: sess.CookieName(), ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.signin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome, FormPolicy: connect.FormPolicy(c.Issuer)}) |
| + return err |
| +} |
| +func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| + page := connect.Page(state, label, func(key string) string { return rastrillo.T(r, "slides.home."+key) }) |
| + var buf bytes.Buffer |
| + if err := pages["home_link"].ExecuteTemplate(&buf, "layout", page); err != nil { |
| + a.logger.Error("slides: render Home account page", "err", err) |
| + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + buf.WriteTo(w) |
| +} |
| +func (a *app) homeUser(r *http.Request, s sessions.Session) (User, error) { |
| + if a.home == nil || s.Method != connect.HomeMethod { |
| + return User{}, errHomeIdentity |
| + } |
| + person, err := a.home.Resolve(r.Context(), s.Subject) |
| + if err != nil { |
| + return User{}, err |
| + } |
| + var u User |
| + if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) { |
| + return User{}, errHomeIdentity |
| + } |
| + return u, nil |
| +} |
| + |
| +type homeMenuKey struct{} |
| + |
| +func homeMenuLabel(r *http.Request) string { |
| + if enabled, _ := r.Context().Value(homeMenuKey{}).(bool); enabled { |
| + return rastrillo.T(r, "slides.home.menu_action") |
| + } |
| + return "" |
| +} |
| +func (a *app) homeMenu(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), homeMenuKey{}, true))) |
| + }) |
| +} |
| + |
| +func (a *app) revokeHome(w http.ResponseWriter, r *http.Request, subject string) { |
| + var u User |
| + if a.home != nil && a.db.G.WithContext(r.Context()).Raw("SELECT u.* FROM users u JOIN home_identity_links l ON l.person_id=u.id WHERE l.issuer=? AND l.subject=?", a.cfg.Home.Issuer, subject).Scan(&u).Error == nil && u.ID != 0 && !a.allowed.allows(u.Email) { |
| + a.offboard(w, r, u.Email) |
| + return |
| + } |
| + if err := a.db.G.WithContext(r.Context()).Exec("DELETE FROM sessions WHERE method=? AND subject=?", connect.HomeMethod, subject).Error; err != nil { |
| + a.logger.Error("slides: revoke Home session", "err", err) |
| + } |
| + if a.homeSessions != nil { |
| + a.homeSessions.SignOut(w, r) |
| + } |
| + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| + http.Redirect(w, r, "/signin?err=removed", http.StatusSeeOther) |
| + } else { |
| + w.WriteHeader(http.StatusForbidden) |
| + } |
| +} |
| + |
| +type exportSessionKey struct{} |
| + |
| +func (a *app) exportPrincipal(r *http.Request) *http.Request { |
| + s, ok := sessions.Current(r) |
| + if !ok || s.Method != connect.HomeMethod { |
| + return r |
| + } |
| + u, err := a.homeUser(r, s) |
| + if err != nil { |
| + return r |
| + } |
| + original := s |
| + s.Subject = u.Email |
| + return sessions.WithSession(r.WithContext(context.WithValue(r.Context(), exportSessionKey{}, original)), s) |
| +} |
| +func restoreExportPrincipal(r *http.Request) *http.Request { |
| + if s, ok := r.Context().Value(exportSessionKey{}).(sessions.Session); ok { |
| + return sessions.WithSession(r, s) |
| + } |
| + return r |
| +} |
diff --git a/internal/slides/home_test.go b/internal/slides/home_test.go| new file mode 100644 |
| index 0000000..608df90 |
| --- /dev/null |
| +++ b/internal/slides/home_test.go |
| @@ -0,0 +1,411 @@ |
| +package slides |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/rastrillo/rastrillo" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +const homeTestOrigin = "https://slides.example.com" |
| + |
| +func homeFixture(t *testing.T) (*http.ServeMux, *app, User) { |
| + mux, a, u, _ := homeFixtureKey(t) |
| + return mux, a, u |
| +} |
| +func homeFixtureKey(t *testing.T) (*http.ServeMux, *app, User, *assertioncrypto.Keypair) { |
| + t.Helper() |
| + d := testDB(t) |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(map[string]string{"test": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + cfg := Config{Mailer: &homeTestMail{}, Env: EnvDevelopment, Origin: homeTestOrigin, Logger: quietLogger(), AllowedAddresses: []string{"owner@example.com"}, Home: &connect.Credentials{Issuer: "https://home.example.com", ConsumerID: "slides-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}} |
| + mux, a, err := New(d, cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + u := User{ID: 72, Email: "owner@example.com", Name: "Existing owner", Colour: "#112233"} |
| + if err := d.G.Create(&u).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + return mux, a, u, key |
| +} |
| +func homeSession(t *testing.T, a *app, subject, method string) *http.Cookie { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + if err := a.homeSessions.SignIn(w, r, sessions.Session{Subject: subject, Method: method, AuthTime: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == a.homeSessions.CookieName() { |
| + return c |
| + } |
| + } |
| + t.Fatal("missing session cookie") |
| + return nil |
| +} |
| +func homeLinked(t *testing.T, a *app, u User) { |
| + t.Helper() |
| + if err := a.db.G.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", a.cfg.Home.Issuer, "home-person", u.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestHomeExistingIdentityNoCreationAndStableActors(t *testing.T) { |
| + mux, a, u := homeFixture(t) |
| + homeLinked(t, a, u) |
| + cookie := homeSession(t, a, "home-person", connect.HomeMethod) |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + r.AddCookie(cookie) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + if w.Code != 200 { |
| + t.Fatalf("home root: %d %s", w.Code, w.Body.String()) |
| + } |
| + var users, decks int64 |
| + a.db.G.Model(&User{}).Count(&users) |
| + a.db.G.Model(&Deck{}).Count(&decks) |
| + if users != 1 || decks != 0 { |
| + t.Fatalf("Home created rows users=%d decks=%d", users, decks) |
| + } |
| + if got := a.presenterActor(r); got != actorFor(u) { |
| + t.Fatalf("actor %q", got) |
| + } |
| + s, _ := a.homeSessions.From(r) |
| + r = sessions.WithSession(r, s) |
| + principal, ok := a.sessionSubject(httptest.NewRecorder(), r) |
| + if !ok || principal != u.Email { |
| + t.Fatal("export owner changed") |
| + } |
| + jobsRequest := a.exportPrincipal(r) |
| + jobsSession, _ := sessions.Current(jobsRequest) |
| + if jobsSession.Subject != u.Email { |
| + t.Fatal("job lookup owner changed") |
| + } |
| + restored, _ := sessions.Current(restoreExportPrincipal(jobsRequest)) |
| + if restored.Subject != "home-person" || restored.Method != connect.HomeMethod { |
| + t.Fatal("job renderer changed verified identity") |
| + } |
| +} |
| +func TestHomeUnknownSubjectNeverMatchesEmailOrSeeds(t *testing.T) { |
| + mux, a, u := homeFixture(t) |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + r.AddCookie(homeSession(t, a, u.Email, connect.HomeMethod)) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + if w.Code == 200 { |
| + t.Fatal("Home subject matched existing email without explicit link") |
| + } |
| + var n int64 |
| + a.db.G.Model(&User{}).Count(&n) |
| + if n != 1 { |
| + t.Fatal("Home created user") |
| + } |
| + a.db.G.Model(&Deck{}).Count(&n) |
| + if n != 0 { |
| + t.Fatal("Home seeded content") |
| + } |
| +} |
| +func TestHomeOffboardingRevokesBothMethodsAndEndsPresentation(t *testing.T) { |
| + mux, a, u := homeFixture(t) |
| + homeLinked(t, a, u) |
| + home := homeSession(t, a, "home-person", connect.HomeMethod) |
| + _ = homeSession(t, a, u.Email, "magiclink") |
| + p := Presentation{Sqid: "testtalk", Actor: actorFor(u), StartedAt: time.Now()} |
| + if err := a.db.G.Create(&p).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + a.allowed = allowlist{addrs: map[string]struct{}{}} |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/decks", strings.NewReader("Title=unexpected")) |
| + r.Header.Set("Origin", homeTestOrigin) |
| + r.AddCookie(home) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatalf("removed POST: %d", w.Code) |
| + } |
| + var n int64 |
| + a.db.G.Table("sessions").Count(&n) |
| + if n != 0 { |
| + t.Fatal("offboarding retained sessions", n) |
| + } |
| + a.db.G.First(&p, p.ID) |
| + if p.EndedAt == nil { |
| + t.Fatal("removed presenter stayed live") |
| + } |
| + a.db.G.Model(&User{}).Count(&n) |
| + if n != 1 { |
| + t.Fatal("offboarding deleted account") |
| + } |
| +} |
| +func TestHomeFrontDoorLocalRecoveryAndCallbackGuard(t *testing.T) { |
| + mux, a, _ := homeFixture(t) |
| + for _, tc := range []struct { |
| + method, path string |
| + status int |
| + }{{"GET", "/signin?return_to=%2Fd%2Fexisting", 303}, {"GET", "/signin/local", 200}, {"GET", "/account/home", 303}, {"POST", "/account/home", 403}, {"POST", handoff.CallbackPath, 400}, {"GET", "/signed-out", 200}} { |
| + r := httptest.NewRequest(tc.method, homeTestOrigin+tc.path, nil) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + if w.Code != tc.status { |
| + t.Errorf("%s %s status %d body %s", tc.method, tc.path, w.Code, w.Body.String()) |
| + } |
| + if tc.path == "/signin/local" && !strings.Contains(w.Body.String(), "slides-signin-form") { |
| + t.Error("local form missing") |
| + } |
| + } |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local", nil) |
| + c := homeSession(t, a, "home-person", connect.HomeMethod) |
| + r.AddCookie(c) |
| + r.AddCookie(c) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + if w.Code != 400 { |
| + t.Fatal("duplicate session bypassed guard") |
| + } |
| + if !strings.Contains(w.Header().Get("Content-Security-Policy"), "form-action 'self' https://home.example.com") { |
| + t.Fatal("Home POST continuation omitted from CSP") |
| + } |
| +} |
| +func TestHomeInvalidConfigurationDoesNotMigrate(t *testing.T) { |
| + d := testDB(t) |
| + _, _, err := New(d, Config{Env: EnvDevelopment, Origin: homeTestOrigin, Home: &connect.Credentials{Issuer: "https://home.example.com"}}) |
| + if err == nil { |
| + t.Fatal("partial config accepted") |
| + } |
| + var n int |
| + if err := d.Writer().QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&n); err != nil || n != 0 { |
| + t.Fatal("invalid configuration changed database", n, err) |
| + } |
| +} |
| + |
| +func TestHomeRealSignedExchangeExplicitLinkAndReplay(t *testing.T) { |
| + mux, a, u, key := homeFixtureKey(t) |
| + jar := map[string]*http.Cookie{} |
| + local := homeSession(t, a, u.Email, "magiclink") |
| + jar[local.Name] = local |
| + call := func(method, path, origin, form string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form)) |
| + if origin != "" { |
| + r.Header.Set("Origin", origin) |
| + } |
| + if form != "" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + } |
| + for _, c := range jar { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + for _, c := range w.Result().Cookies() { |
| + if c.MaxAge < 0 { |
| + delete(jar, c.Name) |
| + } else { |
| + jar[c.Name] = c |
| + } |
| + } |
| + return w |
| + } |
| + start := call("POST", "/account/home", homeTestOrigin, "") |
| + if start.Code != 303 { |
| + t.Fatal("start", start.Code, start.Body.String()) |
| + } |
| + auth := call("GET", start.Header().Get("Location"), "", "") |
| + target, err := url.Parse(auth.Header().Get("Location")) |
| + if err != nil || target.Query().Get("max_age") != "300" { |
| + t.Fatal("fresh Home request absent", auth.Code, auth.Header()) |
| + } |
| + id, _ := assertion.RandomID() |
| + now := time.Now().Unix() |
| + token, err := assertion.Sign(key, assertion.Claims{Version: 1, Issuer: a.cfg.Home.Issuer, Audience: homeTestOrigin, Subject: "linked-home-person", KeyID: "test", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + callback := call("POST", handoff.CallbackPath, a.cfg.Home.Issuer, url.Values{"assertion": {token}}.Encode()) |
| + if callback.Code != 303 { |
| + t.Fatal("signed callback", callback.Code, callback.Body.String()) |
| + } |
| + finish := call("GET", handoff.FinishPath, "", "") |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| + t.Fatal("finish", finish.Code, finish.Header(), finish.Body.String()) |
| + } |
| + var links int64 |
| + a.db.G.Table("home_identity_links").Count(&links) |
| + if links != 0 { |
| + t.Fatal("link before explicit confirmation") |
| + } |
| + confirm := call("POST", "/account/home/confirm", homeTestOrigin, "") |
| + if confirm.Code != 303 { |
| + t.Fatal("confirm", confirm.Code, confirm.Body.String()) |
| + } |
| + person, err := a.home.Resolve(context.Background(), "linked-home-person") |
| + if err != nil || person.ID != u.ID { |
| + t.Fatal("existing identity lost", person, err) |
| + } |
| + if jar[a.homeSessions.CookieName()].Value != local.Value { |
| + t.Fatal("link silently replaced local session") |
| + } |
| + replay := call("POST", handoff.CallbackPath, a.cfg.Home.Issuer, url.Values{"assertion": {token}}.Encode()) |
| + if replay.Code < 400 { |
| + t.Fatal("assertion replay accepted", replay.Code) |
| + } |
| + var users, decks int64 |
| + a.db.G.Model(&User{}).Count(&users) |
| + a.db.G.Model(&Deck{}).Count(&decks) |
| + if users != 1 || decks != 0 { |
| + t.Fatal("link created data", users, decks) |
| + } |
| +} |
| + |
| +func TestHomeDeletedLocalIDCannotBeReused(t *testing.T) { |
| + _, a, u := homeFixture(t) |
| + homeLinked(t, a, u) |
| + if err := a.db.G.Delete(&u).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + replacement := User{Email: u.Email} |
| + if err := a.db.G.Create(&replacement).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if replacement.ID <= u.ID { |
| + t.Fatal("user IDs may be reused", replacement.ID, u.ID) |
| + } |
| + if _, err := a.home.Resolve(context.Background(), "home-person"); err == nil { |
| + t.Fatal("deleted user's link reassigned to replacement") |
| + } |
| +} |
| +func TestHomePolicyTracksActualFrameworkAndStandalone(t *testing.T) { |
| + bare := http.NewServeMux() |
| + bare.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| + baseline, closeBase, err := rastrillo.Handler(rastrillo.Options{Mux: bare}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeBase() |
| + w := httptest.NewRecorder() |
| + baseline.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/", nil)) |
| + policy := w.Header().Get("Content-Security-Policy") |
| + mux, a, _ := homeFixture(t) |
| + want := strings.Replace(policy, "form-action 'self'", "form-action 'self' "+a.cfg.Home.Issuer, 1) |
| + if want == policy { |
| + t.Fatal("framework form policy changed") |
| + } |
| + wrapped, closeHome, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeHome() |
| + for _, path := range []string{"/signin/local", "/account/home", "/auth/start", "/signed-out"} { |
| + rec := httptest.NewRecorder() |
| + wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil)) |
| + if got := rec.Header().Get("Content-Security-Policy"); got != want { |
| + t.Fatal("Home CSP drift", path, got, want) |
| + } |
| + } |
| + plain, app, err := New(testDB(t), Config{Env: EnvDevelopment, Origin: homeTestOrigin}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer app.Close() |
| + standalone, closePlain, err := rastrillo.Handler(rastrillo.Options{Mux: plain}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closePlain() |
| + rec := httptest.NewRecorder() |
| + standalone.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/signin", nil)) |
| + if rec.Header().Get("Content-Security-Policy") != policy { |
| + t.Fatal("standalone CSP changed") |
| + } |
| +} |
| + |
| +type homeTestMail struct{ body string } |
| + |
| +func (m *homeTestMail) Send(_ context.Context, _, _ string, body string) error { |
| + m.body = body |
| + return nil |
| +} |
| +func TestHomeLocalPluginRecoveryRotatesThenReturnsToLink(t *testing.T) { |
| + mux, a, u := homeFixture(t) |
| + old := homeSession(t, a, "unlinked-home", connect.HomeMethod) |
| + jar := []*http.Cookie{old} |
| + run := func(method, path, form string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form)) |
| + for _, c := range jar { |
| + r.AddCookie(c) |
| + } |
| + if method == "POST" { |
| + r.Header.Set("Origin", homeTestOrigin) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + } |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, r) |
| + return w |
| + } |
| + begin := run("GET", "/account/home", "") |
| + if begin.Code != 303 || begin.Header().Get("Location") != "/signin/local" { |
| + t.Fatal("recovery did not begin", begin.Code) |
| + } |
| + for _, c := range begin.Result().Cookies() { |
| + if c.MaxAge > 0 { |
| + jar = append(jar, c) |
| + } |
| + } |
| + sent := run("POST", "/signin", url.Values{"address": {u.Email}}.Encode()) |
| + if sent.Code != 303 { |
| + t.Fatal(sent.Code) |
| + } |
| + body := a.cfg.Mailer.(*homeTestMail).body |
| + link := regexp.MustCompile(`https://[^\s<>]+/auth/verify\?token=[A-Za-z0-9_-]+`).FindString(body) |
| + if link == "" { |
| + t.Fatal("local fixture link missing") |
| + } |
| + parsed, _ := url.Parse(link) |
| + verify := run("GET", parsed.RequestURI(), "") |
| + if verify.Code != 303 || verify.Header().Get("Location") != connect.LocalCompletePath { |
| + t.Fatal("local verify did not reach continuation", verify.Code, verify.Header()) |
| + } |
| + for _, c := range verify.Result().Cookies() { |
| + if c.Name == old.Name && c.MaxAge >= 0 { |
| + jar[0] = c |
| + } |
| + } |
| + if jar[0].Value == old.Value { |
| + t.Fatal("local verification retained old session") |
| + } |
| + stale := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + stale.AddCookie(old) |
| + if _, ok := a.homeSessions.From(stale); ok { |
| + t.Fatal("old session not revoked") |
| + } |
| + done := run("GET", connect.LocalCompletePath, "") |
| + if done.Code != 303 || done.Header().Get("Location") != "/account/home" { |
| + t.Fatal("recovery lost link navigation", done.Code, done.Header()) |
| + } |
| + var n int64 |
| + a.db.G.Model(&User{}).Count(&n) |
| + if n != 1 { |
| + t.Fatal("recovery created user") |
| + } |
| + a.db.G.Model(&Deck{}).Count(&n) |
| + if n != 0 { |
| + t.Fatal("recovery seeded deck") |
| + } |
| +} |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index 1911882..32b52be 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -547,3 +547,41 @@ slides.export.error.open = "This deck couldn't be opened. Try again in a moment. |
| slides.export.error.render = "The PDF couldn't be made. Try again, and tell us if it keeps happening." |
| slides.export.error.toobig = "This PDF is larger than we can hold. Export it without the notes, or split the deck." |
| slides.export.error.store = "The PDF was made but couldn't be stored. Try again." |
| + |
| +slides.home.signedout_title = "Signed out" |
| + |
| +slides.home.signedout_body = "You can sign in again with Home." |
| + |
| +slides.home.signedout_action = "Sign in with Home" |
| + |
| +slides.home.start_title = "Connect your account to Home" |
| + |
| +slides.home.start_body = "Use Home to sign in to this Slides account. Your content and sharing settings stay as they are." |
| + |
| +slides.home.start_action = "Continue to Home" |
| + |
| +slides.home.cancel_action = "Back" |
| + |
| +slides.home.confirm_title = "Confirm account connection" |
| + |
| +slides.home.confirm_body = "Connect the account below to the Home account you just signed into." |
| + |
| +slides.home.confirm_action = "Connect accounts" |
| + |
| +slides.home.unknown_title = "Home account not connected" |
| + |
| +slides.home.unknown_body = "Sign in to your existing Slides account, then connect Home from the account menu." |
| + |
| +slides.home.local_action = "Sign in to your existing Slides account" |
| + |
| +slides.home.failed_title = "Could not connect accounts" |
| + |
| +slides.home.failed_body = "Return to Slides and start again." |
| + |
| +slides.home.success_title = "Accounts connected" |
| + |
| +slides.home.success_body = "You can now use Home to sign in to this Slides account." |
| + |
| +slides.home.documents_action = "Back to Slides" |
| + |
| +slides.home.menu_action = "Connect Home account" |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| index ed8ef5a..6002043 100644 |
| --- a/internal/slides/presentation.go |
| +++ b/internal/slides/presentation.go |
| @@ -42,6 +42,7 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "crypto/rand" |
| "crypto/sha256" |
| "crypto/subtle" |
| @@ -250,6 +251,14 @@ func (a *app) presenterActor(r *http.Request) string { |
| if a.auth == nil { |
| return "" |
| } |
| + if a.home != nil && a.homeSessions != nil { |
| + if session, ok := a.homeSessions.From(r); ok && session.Method == connect.HomeMethod { |
| + if u, err := a.homeUser(r, session); err == nil { |
| + return actorFor(u) |
| + } |
| + return "" |
| + } |
| + } |
| id, ok := a.auth.SessionFrom(r) |
| if !ok || id.Address == "" { |
| return "" |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index d770b54..16212ab 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -1,6 +1,7 @@ |
| package slides |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| "html/template" |
| @@ -49,7 +50,7 @@ var pages = map[string]*template.Template{} |
| // publicPages are the screens a signed-out request can reach. They get |
| // no account menu, because there is no account to show. |
| -var publicPages = []string{"signin", "errors"} |
| +var publicPages = []string{"signin", "errors", "home_link"} |
| // signedInPages are the screens behind the session gate. They are built |
| // with templates/account.html parsed after the layout, so the shell's |
| @@ -101,6 +102,9 @@ var capabilityPages = map[string][]string{ |
| func init() { |
| for _, name := range publicPages { |
| pages[name] = buildPage(name) |
| + if name == "home_link" { |
| + pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| + } |
| } |
| for _, name := range signedInPages { |
| pages[name] = buildPage(name, "templates/account.html") |
diff --git a/internal/slides/templates/account.html b/internal/slides/templates/account.html| index 0976a08..a357b68 100644 |
| --- a/internal/slides/templates/account.html |
| +++ b/internal/slides/templates/account.html |
| @@ -37,6 +37,7 @@ |
| person's decks. A link and not a form — it is a screen, and |
| minting is a decision made on that screen rather than from a |
| menu. */}} |
| +{{if .HomeLabel}}<a href="/account/home">{{.HomeLabel}}</a>{{end}} |
| <a href="/agents">{{.AgentsLabel}}</a> |
| {{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect |
| guards state-changing methods, and a GET that ends a session is one |
diff --git a/internal/slides/templates/home_link.html b/internal/slides/templates/home_link.html| new file mode 100644 |
| index 0000000..9000fcd |
| --- /dev/null |
| +++ b/internal/slides/templates/home_link.html |
| @@ -0,0 +1,2 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}}{{template "home-connect" .}}{{end}} |