diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d613c..8ec85f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version the pointer to the one-time setup link, in the same column as the form. The signed-out column is sized through `.sp-form`, not through `form` (owner, 2026-10-09). +- panel-redesign (design): the Notifications boxes are out of the Account + and Server › Settings mockups. Event notifications are not built + (panel-notifications is a candidate), and 2.0 carries no box for a feature + that does not exist; they are drawn again as the first step of that + feature (owner, 2026-10-10). +- panel-redesign (stage 2): **Overview**, **Server › Health**, **Account** + and **Server › Settings** are in the new design. Overview shows the verdict + as a stamp with one sentence, the six checks as cards and both domain + lists; Health holds the tables behind the cards. Both refresh themselves. + The Queue card reads "Empty" or "N queued". `status` and `settings` are + off the ratchet. ### Changed diff --git a/docs/assets/panel-redesign/evidence/account.png b/docs/assets/panel-redesign/evidence/account.png new file mode 100644 index 0000000..a98f1f3 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/account.png differ diff --git a/docs/assets/panel-redesign/evidence/health.png b/docs/assets/panel-redesign/evidence/health.png new file mode 100644 index 0000000..b7b27b0 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/health.png differ diff --git a/docs/assets/panel-redesign/evidence/overview.png b/docs/assets/panel-redesign/evidence/overview.png new file mode 100644 index 0000000..f2cb1a0 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/overview.png differ diff --git a/docs/assets/panel-redesign/evidence/settings.png b/docs/assets/panel-redesign/evidence/settings.png new file mode 100644 index 0000000..15583df Binary files /dev/null and b/docs/assets/panel-redesign/evidence/settings.png differ diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 2735bcc..34fd108 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -176,8 +176,8 @@ list, an action under the thing it changes. | system-log | `GET /server/log` | `/system-log` | | backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` | | users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` | -| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits; plus the notifications switch | -| account | `GET /account` · `POST /account/profile`, `/password`, `/notifications`, `/dmarc` | `/settings`: credentials and the DMARC default (today `/account` redirects there) | +| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits | +| account | `GET /account` · `POST /account/profile`, `/password`, `/dmarc` | `/settings`: credentials and the DMARC default (today `/account` redirects there) | | help · components | `GET /help` · `GET /server/components` | `/help` · — | Unchanged: `/healthz`, `/license`, `/static/`, `/setup/{token}`, `/login`, @@ -232,7 +232,7 @@ the first step of that feature's plan — not invented during implementation. | inbound-antispam-panel (agreed, `2.1.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` | | inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound | | preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` | -| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | `account`, `settings` | +| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | **no** — drawn once, taken out of `account` and `settings` on 2026-10-10 (owner): 2.0 ships no box for a feature that is not built; redrawn as the first step of its plan | | password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** | | delivery-log-storage (candidate) | the *Delivery log* box of **Outbound › Log › message**, filled from a table instead of a grep of today's `mail.log` | `log_pane`, unchanged | `out-message` | | csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a | @@ -514,7 +514,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) - [x] Signed-out pages: login, setup — **Sonnet** -- [ ] Overview, Health, and the Account / Settings split — **Sonnet** +- [x] 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** - [ ] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet** diff --git a/docs/roadmap.md b/docs/roadmap.md index b776bc7..b4b7086 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 | 16/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | +| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 17/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/authz.go b/internal/web/handlers/authz.go index 4a3bfa6..a2d088e 100644 --- a/internal/web/handlers/authz.go +++ b/internal/web/handlers/authz.go @@ -6,6 +6,7 @@ import ( "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/view" ) func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) { @@ -32,6 +33,18 @@ func (h *Handlers) pageBase(r *http.Request) map[string]any { } } +// shellMeta is what the shell of a page of the component kit needs to know +// about the person looking at it: their name and which groups of the menu they +// reach. The page's constructor adds its title and where it sits in the menu. +func (h *Handlers) shellMeta(r *http.Request) view.Meta { + p, _ := h.principal(r) + user := auth.CurrentUser(r) + if user == "" { + user = p.Username + } + return view.Meta{User: user, IsGlobal: p.IsGlobal(), HasOutbound: p.HasOutbound(), HasInbound: p.HasInbound()} +} + func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) { if p.IsGlobal() { return h.store.ListDomains() diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go index 290929d..82f71cd 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -10,6 +10,7 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "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" ) @@ -61,19 +62,12 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) { - data := h.pageBase(r) - data["Title"] = "SelfPost — account" - data["Active"] = "account" - data["AccountPage"] = true - data["Role"] = u.Role - data["FormUsername"] = f.Username - data["FormEmail"] = f.Email - data["FormDMARCMode"] = f.DMARCMode - data["FormDMARCAddress"] = f.DMARCAddress - data["AccountEmail"] = u.Email - data["Error"] = f.Err - data["Flash"] = accountFlash(r) - data["DMARCIngestEnabled"] = h.dmarc != nil && h.cfg.DMARCEnabled + hosted := h.dmarc != nil && h.cfg.DMARCEnabled + page := view.NewAccount(h.shellMeta(r), string(u.Role), hosted, u.Email) + page.Username, page.Email = f.Username, f.Email + page.DMARCSelected, page.DMARCAddress = f.DMARCMode, f.DMARCAddress + page.WithResult(accountFlash(r), f.Err) + page.WithDomainUse(h.domainsFollowing(r, u)) // The stored default, not the one being typed: the authorization record // belongs to the address domains are actually told to publish. @@ -81,14 +75,33 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status if addr := def.Resolve(""); addr != "" && def.Mode != store.DMARCDefaultHosted { if hub := dnscheck.EmailDomain(addr); hub != "" { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - data["ReportAuthDNS"] = h.dns.ReportAuth(ctx, hub) + ra := h.dns.ReportAuth(ctx, hub) cancel() - data["ReportAuthName"] = dnscheck.ReportAuthRecordName(hub) - data["ReportAuthExample"] = dnscheck.ReportAuthExample() - data["ReportAuthHub"] = hub + page.WithAuthorization(dnscheck.ReportAuthRecordName(hub), dnscheck.ReportAuthExample(), + string(ra.Status), ra.Detail, ra.Records) } } - h.view.Render(w, status, "settings", data) + h.view.Render(w, status, "account", page) +} + +// domainsFollowing counts the outbound domains the user reaches and how many of +// them take their report address from this user's default. +func (h *Handlers) domainsFollowing(r *http.Request, u store.User) (following, total int) { + p, ok := h.principal(r) + if !ok { + return 0, 0 + } + domains, err := h.assignedDomains(p) + if err != nil { + logf("panel: account: list domains: %v", err) + return 0, 0 + } + for _, d := range domains { + if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID { + following++ + } + } + return following, len(domains) } func accountFlash(r *http.Request) string { diff --git a/internal/web/handlers/handlers_account_test.go b/internal/web/handlers/handlers_account_test.go index 2cd4a6d..1f2ba28 100644 --- a/internal/web/handlers/handlers_account_test.go +++ b/internal/web/handlers/handlers_account_test.go @@ -170,3 +170,102 @@ func TestAccountIsOpenToADomainAdministrator(t *testing.T) { t.Errorf("another user's e-mail was changed to %q", admin.Email) } } + +// Account is drawn by the shell of the kit, for the signed-in person, with +// their role in the kicker. +func TestAccountPageIsDrawnInTheShell(t *testing.T) { + h, _ := settingsServer(t) + out := getBody(t, h.HandleAccount, "/account") + for _, want := range []string{ + `class="navbar is-primary"`, `Signed in as admin · global`, `