diff --git a/CHANGELOG.md b/CHANGELOG.md index 268e695..c0c6583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version verdict per check — machine, processes, TLS certificate, queue, milter sockets, reverse DNS — each leading to its table on Health; a check that could not run shows as a warning, never as fine. Both read the same checks. +- panel-redesign (stage 2): **Sign in** and **Create administrator** are the + first pages in the new design - the split brand / form screen, built from + the component kit. Sign in names the mail host above its title; a refused + attempt is shown as a notice above the fields. `login` and `setup` are off + the ratchet. ### Changed diff --git a/docs/assets/panel-redesign/evidence/login.png b/docs/assets/panel-redesign/evidence/login.png new file mode 100644 index 0000000..66a4496 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/login.png differ diff --git a/docs/assets/panel-redesign/evidence/setup.png b/docs/assets/panel-redesign/evidence/setup.png new file mode 100644 index 0000000..25e0024 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/setup.png differ diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index c8718ed..2735bcc 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -513,7 +513,7 @@ only when both are done. Every stage-2 step ends with the evidence of **Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs) -- [ ] Signed-out pages: login, setup — **Sonnet** +- [x] Signed-out pages: login, setup — **Sonnet** - [ ] Overview, Health, and the Account / Settings split — **Sonnet** - [ ] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet** - [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet** diff --git a/docs/roadmap.md b/docs/roadmap.md index ff42b6b..b776bc7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | panel-notifications | E-mail notifications about important events | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — | -| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 15/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | +| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 16/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows diff --git a/internal/web/auth/handlers.go b/internal/web/auth/handlers.go index bfce960..1379333 100644 --- a/internal/web/auth/handlers.go +++ b/internal/web/auth/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/validate" + "github.com/mixeme/selfpost/internal/web/view" "golang.org/x/crypto/bcrypt" ) @@ -74,11 +75,7 @@ func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) { return } if !exists { - m.view.Render(w, http.StatusOK, "login", map[string]any{ - "Title": "SelfPost — Sign in", - "Active": "login", - "SetupHint": true, - }) + m.view.Render(w, http.StatusOK, "login", view.NewLoginSetupHint(m.cfg.Hostname)) return } @@ -94,11 +91,7 @@ func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) { } func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) { - m.view.Render(w, status, "login", map[string]any{ - "Title": "SelfPost — Sign in", - "Active": "login", - "Error": formErr, - }) + m.view.Render(w, status, "login", view.NewLogin(m.cfg.Hostname, formErr)) } func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) { @@ -196,12 +189,7 @@ func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) { } func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) { - m.view.Render(w, status, "setup", map[string]any{ - "Title": "SelfPost — Create administrator", - "Active": "setup", - "Token": token, - "Error": formErr, - }) + m.view.Render(w, status, "setup", view.NewSetup(token, formErr)) } func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) { diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index b2a1b50..a9c6151 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -16,4 +16,6 @@ package view // restyles a page adds its fixture here in the same commit. var pageFixtures = map[string]func() any{ "components": func() any { return KitPage() }, + "login": func() any { return NewLogin("mail.example.org", "") }, + "setup": func() any { return NewSetup("0123456789abcdef", "") }, } diff --git a/internal/web/view/legacy_pages.txt b/internal/web/view/legacy_pages.txt index 3ca6077..88cf13c 100644 --- a/internal/web/view/legacy_pages.txt +++ b/internal/web/view/legacy_pages.txt @@ -23,10 +23,8 @@ help inbound inbound_delete inbound_domain -login mail_queue settings -setup status system_log user_delete diff --git a/internal/web/view/signin.go b/internal/web/view/signin.go new file mode 100644 index 0000000..6775666 --- /dev/null +++ b/internal/web/view/signin.go @@ -0,0 +1,53 @@ +package view + +// The data of the two signed-out pages, sign-in and first-run setup. Neither has +// a user, so their Meta carries a Title and nothing else: Engine.Render then +// draws them with layout_signed_out, the split brand / form screen, and the +// page template supplies only the form column's content. + +// Login is the data of the sign-in page (templates/login.html). Host is the +// name the panel is served as, shown above the title so the person knows which +// server is asking for a password; it is left out when the name is not known. +// SetupHint replaces the form by a pointer to the one-time setup link, for a +// panel that has no administrator yet. Error is the refusal of the last attempt +// ("Too many attempts…"); the page shows it as a flash above the fields. +type Login struct { + Meta + Host string + SetupHint bool + Error string +} + +// NewLogin returns the sign-in page's data. host and formErr may be empty. +func NewLogin(host, formErr string) *Login { + return &Login{Meta: Meta{Title: "Sign in"}, Host: host, Error: formErr} +} + +// NewLoginSetupHint returns the sign-in page's data for a panel with no +// administrator yet: no form, only the way to the setup link. +func NewLoginSetupHint(host string) *Login { + return &Login{Meta: Meta{Title: "Sign in"}, Host: host, SetupHint: true} +} + +// Notice is the page's error as the flash partial's input; it is read only when +// Error is set. +func (l *Login) Notice() Flash { return Flash{Error: true, Text: Plain(l.Error)} } + +// Setup is the data of the first-run page that creates the administrator +// (templates/setup.html), reached by the one-time link /setup/. Token is +// the path segment the form posts back to; Error is the refusal of the last +// submission, shown as a flash above the fields. +type Setup struct { + Meta + Token string + Error string +} + +// NewSetup returns the setup page's data. formErr may be empty. +func NewSetup(token, formErr string) *Setup { + return &Setup{Meta: Meta{Title: "Create administrator"}, Token: token, Error: formErr} +} + +// Notice is the page's error as the flash partial's input; it is read only when +// Error is set. +func (s *Setup) Notice() Flash { return Flash{Error: true, Text: Plain(s.Error)} } diff --git a/internal/web/view/signin_test.go b/internal/web/view/signin_test.go new file mode 100644 index 0000000..0104d2a --- /dev/null +++ b/internal/web/view/signin_test.go @@ -0,0 +1,92 @@ +package view + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func renderSignedOut(t *testing.T, page string, data any) string { + t.Helper() + engine, err := New("9.9.9-test") + if err != nil { + t.Fatalf("New: %v", err) + } + rec := httptest.NewRecorder() + engine.Render(rec, http.StatusOK, page, data) + if rec.Code != http.StatusOK { + t.Fatalf("render %s: status %d: %s", page, rec.Code, rec.Body.String()) + } + return rec.Body.String() +} + +func TestLoginFormPostsToLoginWithItsFields(t *testing.T) { + out := renderSignedOut(t, "login", NewLogin("mail.example.org", "")) + for _, want := range []string{ + `action="/login"`, `method="post"`, + `name="username"`, `autocomplete="username"`, `autofocus`, + `name="password"`, `autocomplete="current-password"`, + `mail.example.org`, + } { + if !strings.Contains(out, want) { + t.Errorf("login page is missing %q", want) + } + } + if strings.Contains(out, "notification") { + t.Error("an untouched login form shows an error") + } +} + +func TestLoginShowsAnErrorEscaped(t *testing.T) { + out := renderSignedOut(t, "login", NewLogin("", `bad `)) + if !strings.Contains(out, "notification is-danger is-light") { + t.Error("the refusal is not shown as the error flash") + } + if strings.Contains(out, "