panel-redesign: three choices for a domain's report address; row actions

Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.

The report address
  - Baseline: users.dmarc_default_mode, users.dmarc_default_address and
    domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
    where reports go: '' is no reports (a new domain), the address
    SelfPost derives for the domain is the hosted choice (while hosted
    reports are on), anything else is a typed address.
  - Removed: the user default and its store functions, a domain
    following a user, keep / inherit, followCreator, POST /account/dmarc
    with its handler, the DMARC box of Account, the user-delete item
    about domains losing their address.
  - Domain settings offers the three choices; beside the typed address a
    "Use my e-mail" button fills in the profile e-mail (panel.js: one
    generic data-fill hook; absent without a profile e-mail; it fills,
    it does not save).
  - The DMARC record's note names the address and no user. This closes
    L4 of the security review.
  - Domain export / import carries the address as a plain string.
  - The ingest allow-list is the generic mailbox plus each domain's
    address on this host; the generic mailbox is always open now.
  - dev/migrate1x (not in the tree): a 1.x domain that inherited the
    instance default gets that address as its own, so no published rua=
    changes.

Row actions
  - row_action partial (RowAction: Label, Post, Confirm, Danger);
    panel.css has the mockup's three rules. A row of the Applications
    table is Edit, New password, Delete; the confirmation texts are the
    ones the application page's head uses. Both roles, with the checks
    of the application page.

Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.

Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.

Evidence
  - docs/assets/panel-redesign/evidence/account.png,
    out-domain-settings.png, out-domain.png, user-delete.png, help.png:
    1440 px wide, headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - panel/audit.js on out-domain, out-domain-settings, account, help,
    components and user-delete at 1440 and 1280: 1420 texts checked,
    problems: [].
  - In a browser: the fill button sets the field and does not submit;
    the row buttons have the font, size and colour of the Edit link,
    Delete the red of the other delete links; data-confirm fires on
    both. Hover and focus states were not observed.
  - go test -count=1 ./internal/web/... ./internal/store/...
    ./internal/dmarc/... ./internal/app/... ./internal/inbound/...
    ./cmd/panel: ok (ten packages; dev/migrate1x also ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 12:38:35 +03:00
co-authored by Claude Opus 5.5
parent e15f8896c5
commit 96db0b7a86
49 changed files with 768 additions and 1214 deletions
+15 -84
View File
@@ -12,30 +12,34 @@ import (
// Account is every user's own page: each part is its own form and its own
// POST, and none of them touches what another saves.
func TestAccountPageShowsThreeForms(t *testing.T) {
func TestAccountPageShowsTwoForms(t *testing.T) {
h, _ := settingsServer(t)
out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{
`action="/account/profile"`, `name="email"`,
`action="/account/password"`, `name="current_password"`,
`action="/account/dmarc"`, `name="dmarc_default"`,
} {
if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want)
}
}
for _, gone := range []string{`name="send_log_retention_days"`, `value="hosted"`} {
// A domain's report address is not an Account setting any more.
for _, gone := range []string{`name="send_log_retention_days"`, `/account/dmarc`, `name="dmarc_default"`, `<h2>DMARC reports</h2>`, `Report authorization`} {
if strings.Contains(out, gone) {
t.Errorf("account page shows %q (instance setting, or hosted reports with ingest off)", gone)
t.Errorf("account page shows %q", gone)
}
}
}
// The profile form saves the name and the e-mail without asking for the
// password and without changing the password or the DMARC default.
// password and without changing the password or any domain's report address.
func TestAccountProfile(t *testing.T) {
h, _ := settingsServer(t)
before, _ := h.store.GetUser(globalPrincipal.ID)
d, _ := h.store.AddDomain("example.com", "mail")
if err := h.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
t.Fatal(err)
}
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
@@ -46,8 +50,11 @@ func TestAccountProfile(t *testing.T) {
if u.Username != "operator" || u.Email != "mix@example.org" {
t.Errorf("profile = %q / %q", u.Username, u.Email)
}
if u.PasswordHash != before.PasswordHash || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("saving the profile changed the password or the DMARC default (%q)", u.DMARCDefaultMode)
if u.PasswordHash != before.PasswordHash {
t.Error("saving the profile changed the password")
}
if got, _ := h.store.GetDomain(d.ID); got.DMARCRua != "reports@hub.example" {
t.Errorf("saving the profile moved a domain's report address to %q", got.DMARCRua)
}
for _, bad := range []url.Values{
@@ -97,62 +104,6 @@ func TestAccountPassword(t *testing.T) {
}
}
// The default report address is the user's choice between four modes; the
// address field belongs to "another address" alone.
func TestAccountDMARCDefault(t *testing.T) {
h, _ := settingsServer(t)
save := func(form url.Values) (int, store.User) {
rec := postFormAs(h.HandleAccountDMARC, globalPrincipal, "/account/dmarc", nil, form)
u, _ := h.store.GetUser(globalPrincipal.ID)
return rec.Code, u
}
// "My account e-mail" needs an e-mail.
if code, u := save(url.Values{"dmarc_default": {"account"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("account e-mail without an e-mail = %d, mode %q", code, u.DMARCDefaultMode)
}
// Hosted needs report ingest, which this server has off.
if code, u := save(url.Values{"dmarc_default": {"hosted"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("hosted with ingest off = %d, mode %q", code, u.DMARCDefaultMode)
}
for _, bad := range []url.Values{
{"dmarc_default": {"custom"}},
{"dmarc_default": {"custom"}, "dmarc_default_address": {"nope"}},
{"dmarc_default": {"profile"}},
{},
} {
if code, _ := save(bad); code != http.StatusBadRequest {
t.Errorf("%v = %d, want 400", bad, code)
}
}
code, u := save(url.Values{"dmarc_default": {"custom"}, "dmarc_default_address": {"dmarc@hub.example"}})
if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultCustom || u.DMARCDefaultAddress != "dmarc@hub.example" {
t.Errorf("another address = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
}
// A domain that follows this user now resolves to it.
d, _ := h.store.AddDomain("example.com", "mail")
h.followCreator(d.ID, globalPrincipal.ID)
d, _ = h.store.GetDomain(d.ID)
if rua, err := h.domainReportAddress(d); err != nil || rua != "dmarc@hub.example" {
t.Errorf("a following domain reports to %q, %v", rua, err)
}
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"admin"}, "email": {"mix@example.org"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("save e-mail = %d", rec.Code)
}
code, u = save(url.Values{"dmarc_default": {"account"}, "dmarc_default_address": {"left-over@hub.example"}})
if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultAccount || u.DMARCDefaultAddress != "" {
t.Errorf("my account e-mail = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
}
if rua, _ := h.domainReportAddress(d); rua != "mix@example.org" {
t.Errorf("the following domain reports to %q, want the account e-mail", rua)
}
if code, u = save(url.Values{"dmarc_default": {"none"}}); code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("none = %d, %q", code, u.DMARCDefaultMode)
}
}
// A domain administrator has the same Account page; it is not a Server page.
func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
h, _ := settingsServer(t)
@@ -178,7 +129,7 @@ func TestAccountPageIsDrawnInTheShell(t *testing.T) {
out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{
`class="navbar is-primary"`, `Signed in as admin · global`, `<h1 class="title is-3">Account</h1>`,
`id="dmarc"`, `href="/static/panel.css"`,
`href="/static/panel.css"`,
} {
if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want)
@@ -222,7 +173,6 @@ func TestAccountResultsAreTheFlash(t *testing.T) {
for done, want := range map[string]string{
"profile": "Profile saved.",
"password": "Password changed.",
"dmarc": "Default report address saved.",
} {
out := getBody(t, h.HandleAccount, "/account?done="+done)
if !strings.Contains(out, `notification is-success is-light`) || !strings.Contains(out, want) {
@@ -247,22 +197,3 @@ func TestAccountPageForADomainAdministrator(t *testing.T) {
}
}
}
// The default address needs an authorization record when it is on another
// domain; with "no reports" nothing is asked of DNS and no record is drawn. The
// count of following domains is the user's own.
func TestAccountReportAuthorizationFollowsTheStoredDefault(t *testing.T) {
h, _ := settingsServer(t)
d, _ := h.store.AddDomain("example.com", "mail")
h.followCreator(d.ID, globalPrincipal.ID)
if _, err := h.store.AddDomain("other.example", "mail"); err != nil {
t.Fatal(err)
}
out := getBody(t, h.HandleAccount, "/account")
if strings.Contains(out, "Report authorization") {
t.Error("an authorization record for a default of no reports")
}
if !strings.Contains(out, "1 of 2 now") {
t.Errorf("the domains following the default are not counted:\n%s", out)
}
}