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
+12 -110
View File
@@ -3,18 +3,14 @@ package view
// The data of Account (templates/account.html, mockup account.html) and of
// Server › Settings (templates/settings.html, mockup settings.html): what used
// to be one Settings page, split by whose it is. Account is the signed-in
// user's own — who they are to the panel, their password and where DMARC reports
// about their domains go — and every role has it. Settings is the instance's.
// user's own — who they are to the panel and their password — and every role
// has it. Settings is the instance's.
import (
"strconv"
"strings"
)
import "strconv"
// Account is the data of the Account page. Three forms, each its own POST: the
// profile (Username, Email), the password, and the default report address
// (DMARCSelected, DMARCAddress). The form fields hold what was stored, or what
// was just submitted when a form is shown again with an error.
// Account is the data of the Account page. Two forms, each its own POST: the
// profile (Username, Email) and the password. The fields hold what was stored,
// or what was just submitted when a form is shown again with an error.
type Account struct {
Meta
Head Head
@@ -25,122 +21,28 @@ type Account struct {
Email string
Password Box
DMARC Box
DMARCOptions []Option
DMARCSelected string
DMARCAddress string
DMARCHelp Text
hosted bool
// Authorization is the report-authorization record the default address
// needs when it is on another domain than the senders; nil when it needs
// none (the hosted address, no reports, an address on the senders' domain).
Authorization *Record
}
// DMARC default choices, the values of the select (store.DMARCDefault*).
const (
DMARCChoiceHosted = "hosted"
DMARCChoiceAccount = "account"
DMARCChoiceCustom = "custom"
DMARCChoiceNone = "none"
)
// NewAccount builds the page for a user. role is "global" or "domain" and
// appears in the kicker. hosted says whether SelfPost-hosted reports are
// offered (report ingest is on), accountEmail is the e-mail the "my account
// e-mail" choice names.
func NewAccount(m Meta, role string, hosted bool, accountEmail string) *Account {
// appears in the kicker.
func NewAccount(m Meta, role string) *Account {
m.Title, m.Section, m.Page = "Account", "user", "account"
a := &Account{
return &Account{
Meta: m,
Head: Head{
Kicker: "Signed in as " + m.User + " · " + role,
Title: "Account",
Lead: Plain("Who you are to this panel and where reports about your domains go. Applications keep their own logins and passwords and are not affected."),
Lead: Plain("Who you are to this panel. Applications keep their own logins and passwords and are not affected."),
},
Profile: Box{No: "01", Title: "Profile"},
Password: Box{No: "02", Title: "Password"},
DMARC: Box{No: "03", Title: "DMARC reports", ID: "dmarc",
Help: &HelpLink{Href: "/help#" + HelpDNS, Title: "DMARC and where its reports go"}},
hosted: hosted,
}
a.WithDomainUse(0, 0)
if hosted {
a.DMARCOptions = append(a.DMARCOptions, Option{Value: DMARCChoiceHosted, Label: "SelfPost hosted — read them under DMARC reports"})
}
account := "My account e-mail"
if accountEmail != "" {
account += " — " + accountEmail
}
a.DMARCOptions = append(a.DMARCOptions,
Option{Value: DMARCChoiceAccount, Label: account},
Option{Value: DMARCChoiceCustom, Label: "Another address…"},
Option{Value: DMARCChoiceNone, Label: "No reports"},
)
return a
}
// WithDomainUse says how many of the user's outbound domains follow their
// default address now (of total); nothing is said when they have none.
func (a *Account) WithDomainUse(following, total int) *Account {
help := Rich("Used by every outbound domain whose report address is set to ", Em(a.Meta.User+"'s default"))
if total > 0 {
help = append(help, Inline{Text: " — " + strconv.Itoa(following) + " of " + strconv.Itoa(total) + " now"})
}
help = append(help, Inline{Text: ". Changing it changes the DMARC record those domains must publish. "})
if a.hosted {
help = append(help, Inline{Text: "The hosted address is parsed by SelfPost; any other just receives the raw XML."})
} else {
help = append(help, Inline{Text: "The address receives the raw XML."})
}
a.DMARCHelp = help
return a
}
// WithResult shows the result of the last action, or its refusal, between the
// head and the first box.
func (a *Account) WithResult(flash, formErr string) *Account {
switch {
case formErr != "":
a.Flash = &Flash{Error: true, Text: Plain(formErr)}
case flash != "":
a.Flash = &Flash{Text: Plain(flash)}
}
return a
}
// WithAuthorization adds the report-authorization record the default address
// needs on its own domain: where to publish it (host, value) and what DNS said
// about it — a status ("ok", "warn", "error" or "unknown"), the sentence that
// explains it and the records found. The tag in the box head repeats the
// verdict.
func (a *Account) WithAuthorization(host, value, status, detail string, found []string) *Account {
rec := &Record{
Name: "Report authorization",
Note: Plain("Needed because the address is on another domain than the senders"),
Host: host, Type: "TXT", Value: value,
}
switch status {
case "ok":
rec.Status = Tag{Status: "published"}
a.DMARC.End = Rich(TagOf("ok", "authorized"))
case "warn", "error":
level := map[string]string{"warn": "warn", "error": "fail"}[status]
rec.Status = Tag{Status: level, Label: "not published"}
if len(found) > 0 {
rec.InDNS = strings.Join(found, " ")
rec.Problem = Plain(detail)
} else {
rec.Note = Plain(detail)
}
a.DMARC.End = Rich(TagOf(level, "not authorized"))
case "unknown":
rec.Status = Tag{Status: "unknown", Label: "not checked"}
rec.Note = Plain(detail)
a.DMARC.End = Rich(TagOf("unknown", "not checked"))
}
a.Authorization = rec
a.Flash = resultFlash(flash, formErr)
return a
}
@@ -169,7 +71,7 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
Head: Head{
Kicker: "Server",
Title: "Settings",
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and DMARC report address are under ",
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under ",
Link("/account", "Account"), "."),
},
Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")},
+12
View File
@@ -198,6 +198,18 @@ type Action struct {
Danger bool
}
// RowAction is an action that changes something, in the last cell of a table
// row (partial row_action; used by the Applications table of a domain). A link
// cannot POST, so it is a form with one button that the stylesheet makes read as
// the links beside it: Post is the address it submits to, Confirm the question
// panel.js asks first (data-confirm), Danger the red of a delete.
type RowAction struct {
Label string
Post string
Confirm string
Danger bool
}
// Postmark is the round verdict stamp inside a Head: three lines of imprint
// and the Level (ok, warn or fail) that colours it.
type Postmark struct {
+16 -19
View File
@@ -120,15 +120,12 @@ func healthFixture(refresh bool) *Health {
return h
}
// accountFixture is the mockup's Account: the global administrator, their
// e-mail as the DMARC default, and the authorization record that default needs.
// accountFixture is the mockup's Account: the global administrator, their name
// and e-mail.
func accountFixture() *Account {
m := admin()
a := NewAccount(m, "global", true, "mix@example.org")
a := NewAccount(admin(), "global")
a.Username, a.Email = "admin", "mix@example.org"
a.DMARCSelected = DMARCChoiceAccount
a.WithDomainUse(2, 3)
return a.WithAuthorization("*._report._dmarc.example.org", "v=DMARC1;", "ok", "", nil)
return a
}
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
@@ -166,15 +163,17 @@ func outDomainFixture() *OutDomain {
SPFRecord("example.org", "v=spf1 ip4:203.0.113.25 ~all", DNSCheck{Status: "error",
Detail: "The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.",
Found: []string{"v=spf1 include:_spf.google.com ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org",
Check: DNSCheck{Status: "ok"}, Source: "admin's default", SettingsHref: "/outbound/domains/1/settings",
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org",
Check: DNSCheck{Status: "ok"}, Source: "example.org@dmarc.mail.example.org", SettingsHref: "/outbound/domains/1/settings",
ReportsHref: "/outbound/dmarc/domains/1"}),
}, "3 min ago")
p.WithApplications([]OutAppRow{
{Login: "prod-server", Senders: []string{"*@example.org"}, Activity: FormatActivity(1102, 96),
Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1"},
Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1",
Actions: AppRowActions(1, 1, "prod-server")},
{Login: "alerts", Senders: []string{"alerts@example.org", "noc@example.org"}, Activity: FormatActivity(182, 14),
Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2"},
Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2",
Actions: AppRowActions(1, 2, "alerts")},
})
p.WithConnection("mail.example.org", true)
p.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1")
@@ -182,17 +181,15 @@ func outDomainFixture() *OutDomain {
return p
}
// outDomainSettingsFixture is the mockup's Domain settings: reports go to the
// default of the signed-in administrator, the rate limit is automatic.
// outDomainSettingsFixture is the mockup's Domain settings: reports go to an
// address typed for the domain, the rate limit is automatic.
func outDomainSettingsFixture() *OutDomainSettings {
p := NewOutDomainSettings(admin(), 1, "example.org", 2, true)
p.WithReportAddress([]Option{
{Value: "inherit", Label: "admin's default — mix@example.org"},
{Value: "hosted", Label: "SelfPost hosted (example.org@dmarc.mail.example.org)"},
{Value: "none", Label: "No aggregate reports"},
{Value: "custom", Label: "Custom address"},
}, "inherit", "", Rich("A default belongs to a user and is set under their ", Link("/account#dmarc", "Account"),
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish."))
{Value: ReportNone, Label: "No reports"},
{Value: ReportHosted, Label: "SelfPost hosted — example.org@dmarc.mail.example.org"},
{Value: ReportCustom, Label: "A specific address"},
}, ReportCustom, "reports@acme.io", "mix@example.org")
p.WithExport(12)
return p.WithRateLimit(DomainRateLimit{
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
+2
View File
@@ -31,6 +31,7 @@ type Kit struct {
Records []Record
Tables Box
TablesFoot Foot
RowAction RowAction
LogPane Box
LogLines []LogLine
Facts Box
@@ -123,6 +124,7 @@ func KitPage() *Kit {
Tables: Box{No: "07", Title: "Table conventions", End: Rich("Always inside ", Code("table-container"), " · an address breaks after its @")},
TablesFoot: Foot{Text: Plain("Page 1 of 9"), Link: &Anchor{Href: "#", Label: "Older →"}},
RowAction: RowAction{Label: "Delete", Post: "#", Danger: true},
LogPane: Box{No: "09", Title: "log_pane", End: Plain("sp-d time · sp-w warning · sp-e error")},
LogLines: []LogLine{
+35 -12
View File
@@ -164,7 +164,7 @@ func SPFRecord(domain, value string, c DNSCheck) Record {
}
// DMARCRecordInput is what a DMARC record needs besides its check: where the
// reports go in words (Source, "admin's default") and the page that changes it,
// reports go in words (Source: the address, or "no reports") and the page that changes it,
// the page of the reports themselves ("" when the panel has none), and whether
// the report address is on the sending domain with nobody to read it there.
type DMARCRecordInput struct {
@@ -316,10 +316,10 @@ type OutDomain struct {
Apps Box
// Days is the length of the send window of the "N days" column.
Days int
AppRows []OutAppRow
AppsEmpty EmptyState
AppsLegend Foot
Days int
AppRows []OutAppRow
AppsEmpty EmptyState
AppsFoot Foot
Connection Box
ConnFacts []Fact
@@ -332,15 +332,15 @@ type OutDomain struct {
// OutAppRow is one line of the Applications table. Senders are the addresses
// the login may send as (*@example.org for the whole domain); Limits are tags,
// the application's own rate limit or "domain" and its client IP allow-list.
// Edit leads to the application's page, where a new password and deleting it
// are one click away: both are POSTs, and a row of a table has no control for
// those (only links).
// Edit leads to the application's page; New password and Delete are POSTs in
// the row itself (RowAction), each asking before it is sent.
type OutAppRow struct {
Login string
Senders []string
Activity string
Limits []Tag
Edit string
Actions []RowAction
}
// NewOutDomain builds the page of a domain. canDelete is the global role: it
@@ -364,6 +364,7 @@ func NewOutDomain(m Meta, id int64, name string, canDelete bool) *OutDomain {
Apps: Box{No: "02", Title: "Applications", ID: "apps", End: Plain("SASL logins that may send as this domain"),
Help: topicLink(HelpApps, "What an application is")},
AppsEmpty: EmptyState{Icon: "ti-apps", Text: Plain("No applications yet. Add one to let a program send as this domain.")},
AppsFoot: Foot{Bare: true, Text: Plain("New password and Delete ask for confirmation; the old password stops working at once.")},
Connection: Box{No: "03", Title: "Connection", ID: "connection", End: Plain("The same for every domain")},
}
}
@@ -529,6 +530,9 @@ type OutDomainSettings struct {
RuaSelected string
RuaCustom string
RuaHelp Text
// RuaMine is the signed-in user's profile e-mail, which the button beside
// the typed address fills the field with; "" leaves the button out.
RuaMine string
Export Box
ExportAction string
@@ -638,11 +642,30 @@ func NewOutDomainSettings(m Meta, id int64, name string, apps int, canDelete boo
return p
}
// The three choices of a domain's report address, the values of the select: no
// reports, the address SelfPost hosts for the domain (offered only while hosted
// reports are enabled) and an address typed for the domain.
const (
ReportNone = "none"
ReportHosted = "hosted"
ReportCustom = "custom"
)
// WithReportAddress sets the report-address form: the choices (the value of an
// option is what the handler reads), the one selected, the custom address
// typed, and the sentence under the select.
func (p *OutDomainSettings) WithReportAddress(options []Option, selected, custom string, help Text) *OutDomainSettings {
p.RuaOptions, p.RuaSelected, p.RuaCustom, p.RuaHelp = options, selected, custom, help
// option is what the handler reads), the one selected, the address typed for
// the domain, and the profile e-mail of the viewer that the button beside the
// field fills it with ("" for none). The sentence under the select says what
// each choice offered means.
func (p *OutDomainSettings) WithReportAddress(options []Option, selected, custom, mine string) *OutDomainSettings {
p.RuaOptions, p.RuaSelected, p.RuaCustom, p.RuaMine = options, selected, custom, mine
help := Rich("No reports leaves ", Code("rua="), " out of the DMARC record. ")
for _, o := range options {
if o.Value == ReportHosted {
help = append(help, Inline{Text: "SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. "})
}
}
help = append(help, Inline{Text: "A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish."})
p.RuaHelp = help
return p
}
+23 -4
View File
@@ -154,6 +154,27 @@ func NewOutApp(m Meta, domainID int64, domain, login string) *OutApp {
return p
}
// newPasswordQuestion and deleteAppQuestion are what panel.js asks before an
// application's password is replaced or the application is removed — the same
// words in the application's head and in its row of the domain's table.
func newPasswordQuestion(login string) string {
return "Generate a new password for " + login + "? The current password stops working immediately."
}
func deleteAppQuestion(login string) string {
return "Delete application " + login + "? Its credentials stop working immediately."
}
// AppRowActions are New password and Delete in an application's row of the
// domain's table: the POSTs of the application's own page, with its questions.
func AppRowActions(domainID, id int64, login string) []RowAction {
own := DomainHref(domainID) + "/applications/" + strconv.FormatInt(id, 10)
return []RowAction{
{Label: "New password", Post: own + "/password", Confirm: newPasswordQuestion(login)},
{Label: "Delete", Post: own + "/delete", Danger: true, Confirm: deleteAppQuestion(login)},
}
}
// WithApplication points an edit form at its application: the POST address, the
// head's buttons (a new password and delete, each asking first) and the mail of
// the statistics window under the title.
@@ -162,10 +183,8 @@ func (p *OutApp) WithApplication(domainID, id int64, total, peak int64, avg stri
p.Action = own
p.Head.Lead = StatsLead(total, peak, avg, days)
p.Head.Actions = []Action{
{Label: "New password", Icon: "ti-key", Post: own + "/password",
Confirm: "Generate a new password for " + p.Head.Title + "? The current password stops working immediately."},
{Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true,
Confirm: "Delete application " + p.Head.Title + "? Its credentials stop working immediately."},
{Label: "New password", Icon: "ti-key", Post: own + "/password", Confirm: newPasswordQuestion(p.Head.Title)},
{Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true, Confirm: deleteAppQuestion(p.Head.Title)},
}
p.recalc = own + "/ratelimit/recalc"
return p
+34 -9
View File
@@ -99,7 +99,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">admin&#39;s default</a>`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">example.org@dmarc.mail.example.org</a>`,
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
)
if got := strings.Count(out, `In DNS now`); got != 1 {
@@ -115,7 +115,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
DKIMRecord("sp2026", "sp2026._domainkey.example.org", "v=DKIM1; p=KEY", DNSCheck{Status: "error", Detail: "No DKIM record was found."}),
SPFRecord("example.org", "v=spf1 -all", DNSCheck{Status: "warn", Detail: "The record ends in ~all.", Found: []string{"v=spf1 ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none", Check: DNSCheck{Status: "unknown", Detail: "DNS could not be reached."},
Source: "none", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}),
Source: "no reports", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}),
ReportAuthRecord("_report._dmarc.hub.example", "v=DMARC1;", DNSCheck{Status: "error", Detail: "Not authorized."}),
}
out = renderSignedIn(t, "out-domain", outDomainFixture().WithRecords(records, "just now"))
@@ -133,14 +133,22 @@ func TestOutDomainApplicationsTable(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out,
`<th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th>`,
`<td class="sp-mono"><strong>prod-server</strong></td>`, `*@<wbr>example.org`,
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `*@<wbr>example.org`,
`alerts@<wbr>example.org, noc@<wbr>example.org`, "1 102 msg · peak 96/h",
`<span class="tag is-success is-light">200 / h</span>`, `<span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span>`,
`<a href="/outbound/domains/1/applications/2">Edit</a>`,
)
// A state change is never a link, and a table row has no control for one:
// a new password and deleting an application are on the application's page.
pageLacks(t, "Domain", out, `/password`, `/applications/1/delete`)
// A state change is never a link: New password and Delete are POST forms in
// the row, each with the question panel.js asks first, drawn like the Edit
// link beside them. Edit leads on to the application's page.
pageHas(t, "Domain", out,
`<td class="sp-actions"><a href="/outbound/domains/1/applications/1">Edit</a>`+
`<form method="post" action="/outbound/domains/1/applications/1/password" data-confirm="Generate a new password for prod-server? The current password stops working immediately."><button type="submit">New password</button></form>`+
`<form method="post" action="/outbound/domains/1/applications/1/delete" data-confirm="Delete application prod-server? Its credentials stop working immediately."><button type="submit" class="has-text-danger">Delete</button></form></td>`,
`action="/outbound/domains/1/applications/2/password" data-confirm="Generate a new password for alerts?`,
`action="/outbound/domains/1/applications/2/delete" data-confirm="Delete application alerts?`,
`New password and Delete ask for confirmation; the old password stops working at once.`,
)
p := outDomainFixture().WithApplications(nil)
out = renderSignedIn(t, "out-domain", p)
@@ -210,8 +218,11 @@ func TestOutDomainSettingsHasAFormPerBox(t *testing.T) {
`<a href="/outbound/domains">Outbound domains</a> /`, `<a href="/outbound/domains/1">example.org</a> /`,
`id="reports"`, `id="limit"`, `id="export"`,
`action="/outbound/domains/1/settings/reports"`, `name="dmarc_rua_mode"`, `data-custom-mode="custom"`, `data-custom-address`,
`name="dmarc_rua_email"`, `<option value="inherit" selected>admin&#39;s default — mix@example.org</option>`,
`<option value="hosted">`, `<option value="none">`, `<option value="custom">`, `href="/account#dmarc"`,
`name="dmarc_rua_email"`, `value="reports@acme.io"`, `<option value="none">No reports</option>`,
`<option value="hosted">SelfPost hosted — example.org@dmarc.mail.example.org</option>`,
`<option value="custom" selected>A specific address</option>`,
`<button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button>`,
`SelfPost hosted sends them to an address on this server`, `Changing this changes the DMARC record to publish.`,
`action="/outbound/domains/1/settings/export"`, `name="encrypt" value="1" checked data-encrypt-toggle`, `data-encrypt-fields`,
`name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`,
`action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`,
@@ -260,11 +271,25 @@ func TestOutDomainSettingsStatesTheFixtureDoesNotDraw(t *testing.T) {
pageLacks(t, "Settings", out, `Delete domain`, `/delete`)
// A refusal comes back as the flash, with the custom address that was typed.
p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "Custom address"}}, "custom", `x"><i>`, nil).
p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "A specific address"}}, "custom", `x"><i>`, "").
WithResult("", "Enter a valid e-mail address.")
out = renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<div class="notification is-danger is-light">`, `Enter a valid e-mail address.`, `<option value="custom" selected>`)
pageLacks(t, "Settings", out, `<i>`)
// Without a profile e-mail there is nothing to fill with: no button. Without
// hosted reports on the server the sentence about them is not there either.
pageLacks(t, "Settings", out, `data-fill`, `Use my e-mail`, `SelfPost hosted`)
}
// The fill button carries the viewer's profile e-mail escaped, and only when
// there is one.
func TestReportAddressFillButton(t *testing.T) {
opts := []Option{{Value: ReportNone, Label: "No reports"}, {Value: ReportCustom, Label: "A specific address"}}
out := renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture().WithReportAddress(opts, ReportNone, "", `a"><i>@x.example`))
pageHas(t, "Settings", out, `data-fill="rua_email" data-fill-value="a&#34;&gt;&lt;i&gt;@x.example"`)
pageLacks(t, "Settings", out, `<i>@`)
out = renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture().WithReportAddress(opts, ReportNone, "", ""))
pageLacks(t, "Settings", out, `data-fill`, `Use my e-mail`)
}
func TestDeleteBoxNamesTheApplicationsItTakes(t *testing.T) {
+14 -46
View File
@@ -250,57 +250,25 @@ func TestNewHealthFlash(t *testing.T) {
// ---- Account
func TestAccountHasThreeFormsEachItsOwnPost(t *testing.T) {
func TestAccountHasTwoFormsEachItsOwnPost(t *testing.T) {
out := renderSignedIn(t, "account", accountFixture())
pageHas(t, "Account", out,
`<h1 class="title is-3">Account</h1>`, `Signed in as admin · global`,
`action="/account/profile"`, `name="username"`, `name="email"`, `value="mix@example.org"`,
`action="/account/password"`, `name="current_password"`, `name="new_password"`, `name="new_password_confirm"`,
`action="/account/dmarc"`, `name="dmarc_default"`, `name="dmarc_default_address"`, `data-custom-mode="custom"`, `data-custom-address`,
`<option value="hosted">`, `<option value="account" selected>My account e-mail — mix@example.org</option>`,
`<option value="custom">`, `<option value="none">`,
`2 of 3 now`, `<em>admin&#39;s default</em>`,
`Your own address. A domain's report address can be filled with it from Domain settings.`,
)
pageLacks(t, "Account", out, `send_log_retention_days`)
// No form asks for another's fields: three forms, three submit buttons.
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 3 {
t.Errorf("Account has %d forms, want 3", got)
// Account is Profile and Password; where a domain's reports go is the
// domain's own setting.
pageLacks(t, "Account", out, `send_log_retention_days`, `/account/dmarc`, `dmarc_default`, `<h2>DMARC reports</h2>`,
`Report authorization`, `default report address`, `id="dmarc"`)
// No form asks for another's fields: two forms, two submit buttons.
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 2 {
t.Errorf("Account has %d forms, want 2", got)
}
}
func TestAccountReportAuthorizationRecord(t *testing.T) {
out := renderSignedIn(t, "account", accountFixture())
pageHas(t, "Account", out,
`<h3>Report authorization</h3>`, `<span class="tag is-success is-light">published</span>`,
`*._report._dmarc.example.org`, `v=DMARC1;`, `<span class="tag is-success is-light">authorized</span>`,
)
pageLacks(t, "Account", out, `In DNS now`)
// Not published: what DNS returned is shown, with the fix.
a := accountFixture().WithAuthorization("_report._dmarc.hub.example", "v=DMARC1;", "error", "The record says something else.", []string{`"v=DMARC2"`})
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `In DNS now`, `&#34;v=DMARC2&#34;`, `The record says something else.`,
`<span class="tag is-danger is-light">not published</span>`, `not authorized`)
// Not found at all: nothing to compare, so the explanation takes the note's place.
a = accountFixture().WithAuthorization("_report._dmarc.hub.example", "v=DMARC1;", "warn", "No record was found.", nil)
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `No record was found.`)
pageLacks(t, "Account", out, `In DNS now`)
// An address that needs no authorization draws no second record.
a = accountFixture()
a.Authorization, a.DMARC.End = nil, nil
out = renderSignedIn(t, "account", a)
pageLacks(t, "Account", out, `Report authorization`)
}
func TestAccountOffersHostedOnlyWithIngest(t *testing.T) {
a := NewAccount(admin(), "global", false, "")
if out := renderSignedIn(t, "account", a); strings.Contains(out, `value="hosted"`) {
t.Error("hosted reports are offered with ingest off")
} else if !strings.Contains(out, `<option value="account">My account e-mail</option>`) {
t.Error("the account e-mail choice names an e-mail the user has not set")
// Two equal columns, one box each.
if got := strings.Count(out, `<div class="column is-6">`); got != 2 {
t.Errorf("Account has %d equal columns, want 2", got)
}
}
@@ -316,9 +284,9 @@ func TestAccountResultAndRefusalAreTheFlash(t *testing.T) {
// What a person typed comes back escaped when a form is shown again.
func TestAccountEscapesTheFormValues(t *testing.T) {
a := accountFixture()
a.Username, a.Email, a.DMARCAddress = `"><script>1</script>`, `a"onfocus="x`, `x"><i>`
a.Username, a.Email = `"><script>1</script>`, `a"onfocus="x`
out := renderSignedIn(t, "account", a)
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`, `<i>`)
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
}
// Event notifications are not built (roadmap: panel-notifications), and a page
+1 -37
View File
@@ -359,9 +359,6 @@ type UserDeleteInput struct {
AllInbound bool
Outbound []string
Inbound []string
// Following names the domains whose DMARC report address is this user's
// default: with the user gone they have none.
Following []string
}
// NewUserDelete builds the page.
@@ -386,7 +383,7 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
}
if in.Global {
p.Consequences = append(p.Consequences, Plain("remove their global access to the server and to every domain."))
return p.withFollowing(in.Following)
return p
}
var out, inb Text
switch {
@@ -409,39 +406,6 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
case len(inb) > 0:
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", inb, " (inbound)."))
}
return p.withFollowing(in.Following)
}
// followingShown is how many domain names the DMARC consequence spells out
// before it says "and N more".
const followingShown = 3
// withFollowing adds what deleting the user does to the domains that report to
// their default address: they are left with none. Nothing is added when no
// domain follows the user.
func (p *UserDelete) withFollowing(domains []string) *UserDelete {
if len(domains) == 0 {
return p
}
shown, more := domains, 0
if len(domains) > followingShown {
shown, more = domains[:followingShown], len(domains)-followingShown
}
var list Text
for i, n := range shown {
switch {
case i == 0:
case i == len(shown)-1 && more == 0:
list = append(list, Inline{Text: " and "})
default:
list = append(list, Inline{Text: ", "})
}
list = append(list, Strong(n))
}
if more > 0 {
list = append(list, Inline{Text: " and " + strconv.Itoa(more) + " more"})
}
p.Consequences = append(p.Consequences, Rich(list, " will have no DMARC report address until someone sets one."))
return p
}
+8 -24
View File
@@ -316,34 +316,18 @@ func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
// change it under Account, and it can receive DMARC reports if they choose.
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
out := renderSignedIn(t, "user", userFixture())
pageHas(t, "User", out, `The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.`)
pageHas(t, "User", out, `The user's own address. They can change it under Account.`)
pageLacks(t, "User", out, `notification`, `reset`)
}
// With domains that follow the user's default report address, deleting them
// leaves those domains with none, and the page says so: up to three names, then
// the count of the rest. With none following, the page says nothing about it.
func TestUserDeleteSaysWhichDomainsLoseTheirReportAddress(t *testing.T) {
for _, tt := range []struct {
following []string
want string
}{
{[]string{"example.org"}, `<strong>example.org</strong> will have no DMARC report address until someone sets one.`},
{[]string{"example.org", "shop.example.org"}, `<strong>example.org</strong> and <strong>shop.example.org</strong> will have no DMARC report address until someone sets one.`},
{[]string{"a.example", "b.example", "c.example"}, `<strong>a.example</strong>, <strong>b.example</strong> and <strong>c.example</strong> will have no DMARC report address`},
{[]string{"a.example", "b.example", "c.example", "d.example", "e.example"}, `<strong>a.example</strong>, <strong>b.example</strong>, <strong>c.example</strong> and 2 more will have no DMARC report address`},
// A domain's report address belongs to the domain, not to a user: deleting a
// user, global or not, says nothing about any domain losing one.
func TestUserDeleteSaysNothingOfReportAddresses(t *testing.T) {
for _, in := range []UserDeleteInput{
{ID: 3, Username: "acme-ops", AllOutbound: true, Outbound: []string{"example.org"}},
{ID: 2, Username: "root", Global: true},
} {
in := UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Following: tt.following}
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), in))
pageHas(t, "Delete user", out, tt.want)
if strings.Contains(out, "d.example") && len(tt.following) > 3 {
t.Errorf("a fourth name is spelled out: %v", tt.following)
}
pageLacks(t, "Delete user", out, `report address`, `will have no`, `The domains`)
}
// A global user's domains follow them as well.
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), UserDeleteInput{ID: 2, Username: "root", Global: true, Following: []string{"example.org"}}))
pageHas(t, "Delete user", out, `will have no DMARC report address`)
out = renderSignedIn(t, "user-delete", userDeleteFixture())
pageLacks(t, "Delete user", out, `DMARC report address`, `The domains`)
}
+8 -1
View File
@@ -218,7 +218,14 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.box > .table-container td:first-child, .box > .table-container th:first-child { padding-left: 1.25rem; }
.box > .table-container td:last-child, .box > .table-container th:last-child { padding-right: 1.25rem; }
.table td.sp-actions { text-align: right; white-space: nowrap; }
.table td.sp-actions a { margin-left: .9rem; }
/* An action that changes state (New password, Delete) is a POST, so it is a form
with a button, not a link. The form sits inline like the links beside it and
the button is handed the link's colour and type; Bulma already resets the
button and shares the pointer and the focus ring between a and button, and a
link here has no hover colour of its own. */
.table td.sp-actions a, .table td.sp-actions form { margin-left: .9rem; }
.table td.sp-actions form { display: inline; }
.table td.sp-actions button { color: var(--bulma-link-text); font: inherit; }
.table td.sp-clip { max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
.sp-empty { padding: 2.2rem 1.25rem; text-align: center; color: var(--sp-muted); }
+20
View File
@@ -42,6 +42,26 @@
});
});
// --- Fill buttons -----------------------------------------------------
// A type="button" with data-fill="<id of an input>" and data-fill-value="<text>"
// puts the text in that input and tells the page it changed. It only fills:
// saving stays the form's own button. Without scripts the button does nothing
// and the field can be typed in.
document.addEventListener("click", function (ev) {
var button = ev.target.closest("button[data-fill]");
if (!button) {
return;
}
var input = document.getElementById(button.dataset.fill);
if (!input) {
return;
}
input.value = button.dataset.fillValue || "";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
input.focus();
});
// --- Confirmation on destructive forms --------------------------------
// Forms that delete something or invalidate a working credential carry a
// data-confirm message. The prompt lives here rather than in an inline
+7 -23
View File
@@ -1,24 +1,26 @@
{{/* Account (mockup account.html): the signed-in user's own — name and e-mail,
password, and where DMARC reports about their domains go. Each form is its
own POST and none of them asks for the others' fields. Every role has this
page; the instance's settings are on Server > Settings (settings.html). */}}
and password. Each form is its own POST and neither asks for the other's
fields. Every role has this page; the instance's settings are on Server >
Settings (settings.html). */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-5">
<div class="column is-6">
{{template "box_open" .Profile}}
{{template "body_open"}}
<form class="sp-form" method="post" action="/account/profile">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Password}}
{{template "body_open"}}
<form class="sp-form" method="post" action="/account/password">
@@ -33,23 +35,5 @@
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-7">
{{template "box_open" .DMARC}}
{{template "record_open"}}
<form class="sp-form" method="post" action="/account/dmarc">
<div class="field"><label class="label" for="dmarc_default">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="dmarc_default" name="dmarc_default" data-custom-mode="custom">{{$sel := .DMARCSelected}}{{range .DMARCOptions}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">{{template "inline" .DMARCHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="dmarc_default_address">Report address</label>
<div class="control"><input class="input" id="dmarc_default_address" name="dmarc_default_address" type="email" value="{{.DMARCAddress}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
{{template "record_close"}}
{{if .Authorization}}{{template "dns_record" .Authorization}}
{{end}}{{template "box_close"}}
</div>
</div>
{{end}}
@@ -36,6 +36,11 @@
never a link. */}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{/* row_action: a POST in the last cell of a table row (td.sp-actions), a form
with one button that is drawn like the links beside it. It sits against the
previous link with no whitespace between, as the cell's spacing is margins. */}}
{{define "row_action"}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit"{{if .Danger}} class="has-text-danger"{{end}}>{{.Label}}</button></form>{{end}}
{{/* ---- page head and postmark */}}
{{/* page_head opens every page (input: Head): the path or section as the
+1 -1
View File
@@ -73,7 +73,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a>{{template "row_action" .RowAction}}</td></tr>
</tbody>
</table></div>
{{template "box_foot" .TablesFoot}}
+4 -3
View File
@@ -22,14 +22,15 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>{{.Days}} days</th><th>Limits</th><th></th></tr></thead>
<tbody>{{range .AppRows}}
<tr><td class="sp-mono"><strong>{{.Login}}</strong></td><td>{{range $i, $a := .Senders}}{{if $i}}, {{end}}{{wbr_at $a}}{{end}}</td><td class="sp-muted">{{.Activity}}</td><td>{{range $i, $t := .Limits}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td>
<td class="sp-actions"><a href="{{.Edit}}">Edit</a></td></tr>{{end}}
<tr><td class="sp-mono sp-nowrap"><strong>{{.Login}}</strong></td><td>{{range $i, $a := .Senders}}{{if $i}}, {{end}}{{wbr_at $a}}{{end}}</td><td class="sp-muted">{{.Activity}}</td><td>{{range $i, $t := .Limits}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td>
<td class="sp-actions"><a href="{{.Edit}}">Edit</a>{{range .Actions}}{{template "row_action" .}}{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .AppsEmpty}}
{{end}}
{{template "box_close"}}
{{if .AppRows}}{{template "box_foot" .AppsFoot}}
{{end}}{{template "box_close"}}
{{template "box_open" .Connection}}
{{template "body_open"}}
@@ -16,8 +16,9 @@
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">{{template "inline" .RuaHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="rua_email">Custom report address</label>
<div class="control"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="{{.RuaCustom}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div>
<div class="field" data-custom-address><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="{{.RuaCustom}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div>
{{if .RuaMine}}<div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="{{.RuaMine}}"><i class="ti ti-mail"></i>Use my e-mail</button></div>{{end}}</div></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
{{template "body_close"}}
+1 -1
View File
@@ -16,7 +16,7 @@
{{template "body_open"}}
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.</p></div>
<p class="help">The user's own address. They can change it under Account.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div>
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
<div class="field"><label class="label" for="role">Role</label>