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:
+12
-110
@@ -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")},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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'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"><i>@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) {
|
||||
|
||||
@@ -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'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`, `"v=DMARC2"`, `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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user