diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e5b70f..580cc3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -107,6 +107,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
whether inbound mail is filtered and what happens while the filter is
down. The filter's address is shown to the global role only. `inbound`,
`inbound_domain` and `inbound_delete` are off the ratchet.
+- panel-redesign (stage 2): the **Server** pages - System log, Backup,
+ Users, the user form and its delete confirmation - and **Help** are in the
+ new design. Help is a page of topics that the `?` in a box's head opens;
+ the help drawer is gone. The user form sets the user's e-mail. The backup
+ download is encrypted unless the box is unticked. Only the queue page is
+ left on the ratchet.
### Changed
@@ -142,6 +148,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a
reported source.
+- The confirmation before deleting a user names the domains that follow
+ that user's default report address and will be left without one; it used
+ to say the domains were not affected.
### Removed
diff --git a/docs/assets/panel-redesign/evidence/backup.png b/docs/assets/panel-redesign/evidence/backup.png
new file mode 100644
index 0000000..da7770f
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/backup.png differ
diff --git a/docs/assets/panel-redesign/evidence/help.png b/docs/assets/panel-redesign/evidence/help.png
new file mode 100644
index 0000000..8c62f92
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/help.png differ
diff --git a/docs/assets/panel-redesign/evidence/system-log.png b/docs/assets/panel-redesign/evidence/system-log.png
new file mode 100644
index 0000000..d0b45f4
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/system-log.png differ
diff --git a/docs/assets/panel-redesign/evidence/user-delete.png b/docs/assets/panel-redesign/evidence/user-delete.png
new file mode 100644
index 0000000..f62b626
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/user-delete.png differ
diff --git a/docs/assets/panel-redesign/evidence/user.png b/docs/assets/panel-redesign/evidence/user.png
new file mode 100644
index 0000000..6d31958
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/user.png differ
diff --git a/docs/assets/panel-redesign/evidence/users.png b/docs/assets/panel-redesign/evidence/users.png
new file mode 100644
index 0000000..6a080b6
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/users.png differ
diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md
index 62e54a3..3118c8c 100644
--- a/docs/plans/panel-redesign.md
+++ b/docs/plans/panel-redesign.md
@@ -518,7 +518,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
- [x] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [x] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
-- [ ] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
+- [x] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
- [ ] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
- [ ] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet**
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b378292..02ea499 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 | 20/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
+| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 21/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/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go
index 82f71cd..22d8795 100644
--- a/internal/web/handlers/handlers_account.go
+++ b/internal/web/handlers/handlers_account.go
@@ -162,9 +162,7 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
}
// The e-mail is the report address of every domain that follows this
// user's "my account e-mail" default.
- if f.Email != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
- h.resyncDMARC("account e-mail")
- }
+ h.resyncAfterEmailChange(u, f.Email)
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
}
@@ -276,6 +274,16 @@ func (h *Handlers) HandleAccountDMARC(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/account?done=dmarc", http.StatusSeeOther)
}
+// resyncAfterEmailChange is what changing a user's account e-mail does for
+// DMARC: the address is the report address of every domain that follows a user
+// whose default is "my account e-mail", so the ingest allow-list is rebuilt. It
+// is the one path both the Account page and the user form take.
+func (h *Handlers) resyncAfterEmailChange(u store.User, newEmail string) {
+ if newEmail != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
+ h.resyncDMARC("account e-mail")
+ }
+}
+
// resyncDMARC rebuilds the ingest allow-list after something that changes
// where a domain's reports go. A failure is logged, not shown: the setting is
// saved, and the next resync picks it up.
diff --git a/internal/web/handlers/handlers_backup.go b/internal/web/handlers/handlers_backup.go
index 3909c61..b706361 100644
--- a/internal/web/handlers/handlers_backup.go
+++ b/internal/web/handlers/handlers_backup.go
@@ -15,6 +15,7 @@ import (
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
+ "github.com/mixeme/selfpost/internal/web/view"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
@@ -45,13 +46,11 @@ func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, stat
// password), importErr to the import card, so neither message appears under the
// wrong form.
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
- data := h.pageBase(r)
- data["Title"] = "SelfPost — backup & migration"
- data["Active"] = "backup"
- data["ImportErr"] = importErr
- data["BackupErr"] = backupErr
- data["MinPwLen"] = validate.MinSecretFilePasswordLen
- h.view.Render(w, status, "backup", data)
+ refusal := importErr
+ if refusal == "" {
+ refusal = backupErr
+ }
+ h.view.Render(w, status, "backup", view.NewBackup(h.shellMeta(r), validate.MinSecretFilePasswordLen, refusal))
}
// HandleBackup streams a full-server backup as a download (architecture.md §
@@ -327,7 +326,7 @@ func decryptErrorMessage(err error) string {
// deployBackupErr phrases a pre-flight backup failure for the operator.
func deployBackupErr(err error) string {
if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") {
- return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add .:/selfpost-deploy:ro to docker-compose.yml and recreate the container."
+ return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add .:/selfpost-deploy:ro to the volumes in docker-compose.yml and recreate the container."
}
return "Could not create the backup: " + err.Error()
}
diff --git a/internal/web/handlers/handlers_help.go b/internal/web/handlers/handlers_help.go
index d8c8dbf..ec1b987 100644
--- a/internal/web/handlers/handlers_help.go
+++ b/internal/web/handlers/handlers_help.go
@@ -3,20 +3,16 @@ package handlers
import (
"net/http"
- "github.com/mixeme/selfpost/internal/web/auth"
+ "github.com/mixeme/selfpost/internal/web/view"
)
+// HandleHelp renders the Help page. It is for both roles; the topics about the
+// checks of Overview and Health are left out for a viewer who cannot see them.
func (h *Handlers) HandleHelp(w http.ResponseWriter, r *http.Request) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- data := map[string]any{
- "Title": "SelfPost — help",
- "User": auth.CurrentUser(r),
- "Active": "help",
- "IsGlobal": p.IsGlobal(),
- }
- h.view.Render(w, http.StatusOK, "help", data)
+ h.view.Render(w, http.StatusOK, "help", view.NewHelp(h.shellMeta(r), p.IsGlobal()))
}
diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go
index 29ad3a1..6deebfc 100644
--- a/internal/web/handlers/handlers_monitor.go
+++ b/internal/web/handlers/handlers_monitor.go
@@ -385,15 +385,7 @@ func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
- lines, errText := h.readLogTail()
- h.view.Render(w, http.StatusOK, "system_log", map[string]any{
- "Title": "SelfPost — system log",
- "User": auth.CurrentUser(r),
- "Active": "system_log",
- "IsGlobal": true,
- "Lines": lines,
- "Error": errText,
- })
+ h.view.Render(w, http.StatusOK, "system-log", h.systemLogPage(r))
}
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
@@ -401,11 +393,21 @@ func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
- lines, errText := h.readLogTail()
- h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
- "Lines": lines,
- "Error": errText,
- })
+ h.view.RenderFragment(w, http.StatusOK, "system_log_body", h.systemLogPage(r))
+}
+
+// systemLogPage reads the tail of mail.log and splits each line into the time
+// and the text the page shows it in, as the delivery log of one message does. A
+// line whose head is not a timestamp the log format recognises keeps its whole
+// text and no time: nothing is dropped from what the log says.
+func (h *Handlers) systemLogPage(r *http.Request) *view.SystemLog {
+ raw, errText := h.readLogTail()
+ lines := make([]view.LogLine, len(raw))
+ for i, line := range raw {
+ stamp, rest := logtail.SplitTimestamp(line)
+ lines[i] = view.LogLine{Time: stamp, Text: rest, Level: view.LogLineLevel(rest)}
+ }
+ return view.NewSystemLog(h.shellMeta(r), lines, errText)
}
func (h *Handlers) readLogTail() ([]string, string) {
diff --git a/internal/web/handlers/handlers_server_email_test.go b/internal/web/handlers/handlers_server_email_test.go
new file mode 100644
index 0000000..d946fca
--- /dev/null
+++ b/internal/web/handlers/handlers_server_email_test.go
@@ -0,0 +1,191 @@
+package handlers
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/mixeme/selfpost/internal/dmarc"
+ "github.com/mixeme/selfpost/internal/postfix"
+ "github.com/mixeme/selfpost/internal/store"
+ "github.com/mixeme/selfpost/internal/web/auth"
+)
+
+// The user form sets the account e-mail: create stores it, an edit changes it,
+// clearing the field clears it, and a post that does not mention the field at
+// all leaves it alone.
+func TestUserFormSetsTheAccountEmail(t *testing.T) {
+ h, st := inboundHandlers(t)
+ out1, _ := st.AddDomain("one.example.com", "mail")
+ root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
+
+ rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
+ "username": {"ops"}, "email": {" ops@acme.io "}, "password": {"a-long-enough-password-1"}, "role": {"domain"},
+ "domain_ids": {idStr(out1.ID)},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("create with an e-mail = %d:\n%s", rec.Code, rec.Body.String())
+ }
+ u, _ := st.GetUserByUsername("ops")
+ if u.Email != "ops@acme.io" || len(u.DomainIDs) != 1 {
+ t.Fatalf("created user = %+v", u)
+ }
+
+ path, pv := "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}
+ post := func(extra url.Values) store.User {
+ t.Helper()
+ form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}}
+ for k, v := range extra {
+ form[k] = v
+ }
+ if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
+ }
+ got, _ := st.GetUser(u.ID)
+ return got
+ }
+ if got := post(url.Values{"email": {"new@acme.io"}}); got.Email != "new@acme.io" {
+ t.Errorf("changed e-mail = %q", got.Email)
+ }
+ if got := post(nil); got.Email != "new@acme.io" {
+ t.Errorf("a post without the field changed the e-mail to %q", got.Email)
+ }
+ if got := post(url.Values{"email": {""}}); got.Email != "" {
+ t.Errorf("clearing the field left %q", got.Email)
+ }
+
+ // Creating without one is fine.
+ rec = send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
+ "username": {"plain"}, "password": {"a-long-enough-password-1"}, "role": {"global"}})
+ if plain, _ := st.GetUserByUsername("plain"); rec.Code != http.StatusSeeOther || plain.Email != "" {
+ t.Errorf("create without an e-mail = %d, %q", rec.Code, plain.Email)
+ }
+}
+
+// A bad address is refused on both forms: nothing is saved, and the form comes
+// back with what was typed, the refusal as the flash.
+func TestUserFormRefusesABadEmailAndKeepsWhatWasTyped(t *testing.T) {
+ h, st := inboundHandlers(t)
+ out1, _ := st.AddDomain("one.example.com", "mail")
+ root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
+ u := userWith(t, st, "ops", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}})
+ if err := st.UpdateUser(u.ID, "ops", "test-hash", "ops@acme.io"); err != nil {
+ t.Fatal(err)
+ }
+
+ rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
+ url.Values{"username": {"ops"}, "email": {"not-an-address"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
+ body := rec.Body.String()
+ for _, want := range []string{`class="notification is-danger is-light"`, "enter a valid email address",
+ `name="email" type="email" value="not-an-address"`, `