panel-redesign: Sign in and Create administrator on the kit
Stage 2, step 1. Written by Sonnet, reviewed by Opus.
- templates/login.html, setup.html: written from the mockups as the
content of layout_signed_out - form.sp-form, kicker, h1, Bulma fields
with icons, the full-width primary button. Actions, field names,
autocomplete and autofocus are the old ones.
- view/signin.go: typed Login and Setup (Meta carries a title only, so
Engine.Render takes the signed-out layout); auth handlers pass them
instead of maps.
- login and setup are in kitPages, have fixtures, and are off
legacy_pages.txt.
States the mockups do not draw:
- a refused sign-in / setup is the kit's flash (notification is-danger
is-light) above the fields;
- login before any administrator exists shows the old sentence in a
form.sp-form that has no action and no control: the signed-out
stylesheet sizes and centres its column through "form" alone. A
workaround, listed for the owner.
Differences from the mockups, for the owner:
- the kicker is <p><span class="sp-kicker"> (the kit's partial is a
span; a page may not write the class), the mockup has <p
class="sp-kicker">: the block sits about 3px lower;
- login's kicker is the configured host name and is absent when there
is none; setup's is the mockup's fixed text;
- the footer is the layout's legal line (full copyright holder name);
- the user name field has autofocus, so it shows its focus ring.
Evidence
- docs/assets/panel-redesign/evidence/login.png, setup.png: 1440x900,
headless Edge, from the real templates served locally with their
fixtures - NOT from the container (no Docker on this machine).
- panel/audit.js on login, login with an error, login without an
administrator, setup, setup with an error, at 1440 and 1280:
118 texts checked, problems: [] ; no page is taller than 900px.
- go test -count=1 ./internal/web/...
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -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**
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", "") },
|
||||
}
|
||||
|
||||
@@ -23,10 +23,8 @@ help
|
||||
inbound
|
||||
inbound_delete
|
||||
inbound_domain
|
||||
login
|
||||
mail_queue
|
||||
settings
|
||||
setup
|
||||
status
|
||||
system_log
|
||||
user_delete
|
||||
|
||||
@@ -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>. 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)} }
|
||||
@@ -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 <script>alert(1)</script>`))
|
||||
if !strings.Contains(out, "notification is-danger is-light") {
|
||||
t.Error("the refusal is not shown as the error flash")
|
||||
}
|
||||
if strings.Contains(out, "<script>alert(1)") || !strings.Contains(out, "<script>alert(1)") {
|
||||
t.Errorf("the refusal is not escaped:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `class="sp-kicker"`) {
|
||||
t.Error("a kicker is shown although the host name is not known")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithoutAnAdministratorHasNoForm(t *testing.T) {
|
||||
out := renderSignedOut(t, "login", NewLoginSetupHint("mail.example.org"))
|
||||
if !strings.Contains(out, "No administrator has been created yet") {
|
||||
t.Error("the setup hint is missing")
|
||||
}
|
||||
// The element stays (the signed-out layout centres the column through it) but
|
||||
// nothing in it can be filled in or sent.
|
||||
for _, bad := range []string{`action=`, `<input`, `<button`, `name="password"`, `name="username"`} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Errorf("the hint page carries %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFormPostsToItsOneTimeLink(t *testing.T) {
|
||||
out := renderSignedOut(t, "setup", NewSetup("abc123", ""))
|
||||
for _, want := range []string{
|
||||
`action="/setup/abc123"`, `method="post"`,
|
||||
`name="username"`, `autocomplete="username"`, `autofocus`,
|
||||
`name="password"`, `name="password_confirm"`, `autocomplete="new-password"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("setup page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "notification") {
|
||||
t.Error("an untouched setup form shows an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupShowsAnErrorEscaped(t *testing.T) {
|
||||
out := renderSignedOut(t, "setup", NewSetup("t", `Passwords <b>do not</b> match.`))
|
||||
if !strings.Contains(out, "notification is-danger is-light") {
|
||||
t.Error("the refusal is not shown as the error flash")
|
||||
}
|
||||
if strings.Contains(out, "<b>do not</b>") {
|
||||
t.Errorf("the refusal is not escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
{{/* Sign-in (mockup login.html): the form column of the signed-out screen. The
|
||||
layout draws the brand half and the legal notice; this is only what stands
|
||||
between them. Before the first administrator exists there is nothing to sign
|
||||
in as, so the form gives way to a pointer at the setup link. */}}
|
||||
{{define "content"}}
|
||||
{{/* The full mark, on the two pages that carry no navigation: with no bar above
|
||||
it, this is the only thing telling an administrator which service just asked
|
||||
them for a password. */}}
|
||||
<img class="mark" src="/static/logo.svg" width="330" height="150" alt="SelfPost">
|
||||
<h1>Sign in</h1>
|
||||
<div class="card narrow">
|
||||
{{if .SetupHint}}
|
||||
<p class="muted">No administrator has been created yet. Open the one-time
|
||||
setup link printed in the container log to get started.</p>
|
||||
{{else}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .SetupHint}}
|
||||
{{/* The signed-out layout sizes and centres its column through "form" alone, so
|
||||
this state, which has no fields, keeps the element; it has no action and no
|
||||
control and cannot be submitted. */}}
|
||||
<form class="sp-form">
|
||||
{{if .Host}}<p>{{template "kicker" .Host}}</p>{{end}}
|
||||
<h1 class="title is-3">Sign in</h1>
|
||||
<p class="sp-muted">No administrator has been created yet. Open the one-time
|
||||
setup link printed in the container log to get started.</p>
|
||||
</form>
|
||||
{{else}}
|
||||
<form class="sp-form" method="post" action="/login">
|
||||
{{if .Host}}<p>{{template "kicker" .Host}}</p>{{end}}
|
||||
<h1 class="title is-3">Sign in</h1>
|
||||
{{if .Error}}{{template "flash" .Notice}}{{end}}
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" autofocus required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">Password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
{{/* First-run setup (mockup setup.html): the form column of the signed-out
|
||||
screen, reached by the one-time link /setup/<token>. */}}
|
||||
{{define "content"}}
|
||||
<img class="mark" src="/static/logo.svg" width="330" height="150" alt="SelfPost">
|
||||
<h1>Create administrator</h1>
|
||||
<div class="card narrow">
|
||||
<p class="muted">This one-time link creates the single panel administrator.
|
||||
After you submit, the link stops working for good.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/setup/{{.Token}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||
|
||||
<label for="password_confirm">Confirm password</label>
|
||||
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
|
||||
|
||||
<button type="submit">Create administrator</button>
|
||||
</form>
|
||||
</div>
|
||||
<form class="sp-form" method="post" action="/setup/{{.Token}}">
|
||||
<p>{{template "kicker" "First run · one-time link"}}</p>
|
||||
<h1 class="title is-3">Create administrator</h1>
|
||||
<p class="sp-muted mb-4">This link creates the single global administrator. After you submit, it stops working for good.</p>
|
||||
{{if .Error}}{{template "flash" .Notice}}{{end}}
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" autofocus required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">Password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password_confirm">Confirm password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -81,21 +81,26 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
|
||||
// Signed out (login, setup) the version must not be advertised, but the
|
||||
// Appropriate Legal Notices must still be present.
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("login").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "Active": "", "Version": "9.9.9-test",
|
||||
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
|
||||
"SourceURL": "https://github.com/mixeme/selfpost",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute login: %v", err)
|
||||
// Both signed-out pages go through Engine.Render, which supplies the
|
||||
// engine's version and the legal lines itself.
|
||||
signedOut := map[string]any{
|
||||
"login": NewLogin("mail.example.org", ""),
|
||||
"setup": NewSetup("token", ""),
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "9.9.9-test") {
|
||||
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", out)
|
||||
}
|
||||
for _, want := range legalBits {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("login page is missing legal notice %q", want)
|
||||
for name, data := range signedOut {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, name, data)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
out := rec.Body.String()
|
||||
if strings.Contains(out, "9.9.9-test") {
|
||||
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
|
||||
}
|
||||
for _, want := range legalBits {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s page is missing legal notice %q", name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,14 +642,18 @@ func TestLoginPageOmitsHelpDrawer(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("login").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "Active": "",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute login: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "help-drawer") {
|
||||
t.Error("login page should not include the help drawer")
|
||||
for name, data := range map[string]any{
|
||||
"login": NewLogin("", ""),
|
||||
"setup": NewSetup("token", ""),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, name, data)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "help-drawer") {
|
||||
t.Errorf("%s page should not include the help drawer", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ var pageFiles = map[string][]string{
|
||||
// pageFiles stays a plain map of file lists because the guard tests read it.
|
||||
var kitPages = map[string]bool{
|
||||
"components": true,
|
||||
"login": true,
|
||||
"setup": true,
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
|
||||
Reference in New Issue
Block a user