Files
selfpost/internal/web/view/account.go
T
mixandClaude Opus 5.5 fa696efa8b panel-redesign: Server pages and Help on the kit; the help drawer is gone
Stage 2, step 6. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: system-log, backup, users,
    user, user-delete, help, with typed page data (view/server.go,
    help.go), fixtures and tests. The old templates of the six are gone
    (user_form.html, encrypt_fields.html deleted, the others rewritten)
    and their names are off legacy_pages.txt, which now holds mail_queue
    alone.
  - Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
    limits); a test renders every fixture page and fails on a Help link
    without a topic. The help drawer (help_drawer.html and its includes
    in layout_legacy.html) is removed; its rules in legacy.css go with
    the next step.
  - The user form sets the user's e-mail (plan, Account e-mail): create
    and update, validated, and a changed address resyncs DMARC for a
    user whose default is the account e-mail - through the helper the
    Account profile now shares.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged. A test loads the user
    form for four reach combinations and for the only global user, posts
    it back untouched and finds the user unchanged.

Fixed on the way
  - The user delete confirmation said the domains were not affected. A
    domain that follows the user's default report address is left with
    none; the page names those domains now.
  - A refused backup lost the encryption choice and printed a literal
    <code> element in its message.

Differences from the mockups, for the owner:
  - Help has no DMARC and no Inbound topic: its outline fixes two boxes
    of three topics, although other mockups link to #dmarc and #inbound.
    Until the owner decides, Account's Help link goes to #dns and the
    inbound MX box has none;
  - system log: newest line first, as drawn; "refreshes on its own" (5 s
    active, 30 s idle); only status=, warning: and error: lines are
    coloured;
  - backup: the file field is a plain file input; the encryption box is
    ticked, as drawn and as on the domain export, so a plain download
    means unticking it first;
  - users: an E-mail column; the flash does not name the user;
  - user form: no lead; the domain lists are no longer hidden by script
    when Global is chosen (the server ignores them for that role).

Evidence
  - docs/assets/panel-redesign/evidence/system-log.png, backup.png,
    users.png, user.png, user-delete.png, help.png: 1440 px wide,
    headless Edge, real templates served locally with their fixtures -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
    problems: [].
  - The system log's polling swap was not exercised in a browser; it
    uses the mechanism of Overview and Health.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:14:25 +03:00

216 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
import (
"strconv"
"strings"
)
// 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.
type Account struct {
Meta
Head Head
Flash *Flash
Profile Box
Username string
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 {
m.Title, m.Section, m.Page = "Account", "user", "account"
a := &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."),
},
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
return a
}
// Settings is the data of Server › Settings: one form with the instance's
// settings and one Save settings button, and the read-only level-1 rate limit
// the operator sets in .env.
type Settings struct {
Meta
Head Head
Flash *Flash
Retention Box
RetentionDays string
RateLimits Box
RateFacts []Fact
}
// NewSettings builds the page. retention is what the field shows (the stored
// value, or what was just submitted); level1 is the per-client-IP limit as
// FormatRate writes it.
func NewSettings(m Meta, retention, level1 string) *Settings {
m.Title, m.Section, m.Page = "Settings", "server", "settings"
return &Settings{
Meta: m,
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 ",
Link("/account", "Account"), "."),
},
Retention: Box{No: "01", Title: "Log retention"},
RetentionDays: retention,
RateLimits: Box{No: "02", Title: "Sending rate limits", ID: "rate-limits", End: Plain("Read-only")},
RateFacts: []Fact{
{Label: "Level 1 · per client IP", Value: Plain(level1), Big: true,
Note: Rich("Set in ", Code(".env"), "; restart to change. Nothing below may exceed it.")},
{Label: "Level 2 · domain, application",
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
},
}
}
// WithResult shows the result of the last action, or its refusal, between the
// head and the form.
func (s *Settings) WithResult(flash, formErr string) *Settings {
switch {
case formErr != "":
s.Flash = &Flash{Error: true, Text: Plain(formErr)}
case flash != "":
s.Flash = &Flash{Text: Plain(flash)}
}
return s
}
// FormatRate writes a rate limit of messages per window the short way:
// "600 / h" for an hourly limit, "30 / min", "5 000 / day", and the window in
// seconds for any other.
func FormatRate(messages, windowSeconds int) string {
n := FormatCount(int64(messages))
switch {
case windowSeconds == 60:
return n + " / min"
case windowSeconds == 3600:
return n + " / h"
case windowSeconds == 86400:
return n + " / day"
case windowSeconds > 0 && windowSeconds%3600 == 0:
return n + " / " + strconv.Itoa(windowSeconds/3600) + " h"
}
return n + " / " + strconv.Itoa(windowSeconds) + " s"
}