Files
selfpost/internal/web/view/server.go
T
mixandClaude Opus 5.5 32c6ae98a1 panel-redesign: the queue in the new shell; the old design is removed
Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.

The queue
  - out-queue, named after its mockup: today's content - the listing of
    postqueue -p in a log pane, polled as before (GET
    /outbound/queue/fragment) - and the "How retries work" facts. The
    mockup's table of messages is the roadmap's queue-as-a-table; the
    outline guard exempts this page for exactly that reason. Routes and
    the global-only check are unchanged; the page has no actions and
    never had.
  - Inbound has no Queue entry and no route serves one.

The old design is gone
  - Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
    mail_queue.html, mail_queue_body.html.
  - view.go has one path: every page is layout.html + components.html +
    its own files; kitPages, kitFragments, the map-data branch of Render,
    the dict and back template functions and metaOf's map branch are
    removed. handlers: pageBase removed.
  - legacy_pages.txt holds its header and no entry.
  - panel.js: the old copy handler (button.copy in .code-row) is
    removed. A show-when rule now applies to every element it matches,
    and hiding adds Bulma's is-hidden beside the hidden attribute - a
    .box or a grid row has a display of its own and stayed visible.
  - The user form hides both domain lists again when the role is Global
    (Box.DomainPick, an attribute; data-global-role on the select).
  - Tests of the old layout are deleted; what they asserted about the
    shell (version only signed in, the menu follows the user's reach,
    Inbound only with the feature, Help present, the current entry
    marked) is asserted on the kit pages, two tests added for it.

For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.

Evidence
  - docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
    headless Edge, the real template served locally with its fixture -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on out-queue, user and backup at 1440 and 1280:
    366 texts checked, problems: [].
  - In a browser against the local fixture server: the encryption
    fields of Backup, the manual / auto limit fields and the custom
    report address of Domain settings show and hide with their control;
    all 28 fixture pages answer 200 with panel.css, /static/legacy.css
    answers 404.
  - Not exercised: polling against a real postqueue (none on Windows).
  - 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:36:04 +03:00

459 lines
14 KiB
Go

package view
// The data of the Server pages that are not a health table: the system log
// (templates/system_log.html, mockup system-log.html), the backup and the
// domain import (backup.html, backup.html), the users list (users.html,
// users.html), the user form (user.html, user.html) and the confirmation before
// a user is deleted (user_delete.html, user-delete.html). Everything under
// /server/ is for the global role.
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads the stores, the view decides what is said
// and how it is worded.
import (
"strconv"
"strings"
)
const (
serverLog = "/server/log"
serverBackup = "/server/backup"
serverUsers = "/server/users"
)
// UserHref is the address of a panel user's form.
func UserHref(id int64) string { return serverUsers + "/" + strconv.FormatInt(id, 10) }
// ---- System log
// SystemLog is the data of the system log: the tail of mail.log, newest line
// first, in the one box that polls. The page and its fragment (GET
// /server/log/fragment) render the same box (templates/system_log_body.html).
type SystemLog struct {
Meta
Head Head
Log Box
Lines []LogLine
// Empty is shown in place of the lines when there are none: the log has
// nothing in it yet, or it could not be read.
Empty EmptyState
}
// NewSystemLog builds the page. lines are the log's tail as it was read, oldest
// first; the page shows them newest first. errText is why the log could not be
// read, and empty when it could.
func NewSystemLog(m Meta, lines []LogLine, errText string) *SystemLog {
m.Title, m.Section, m.Page = "System log", "server", "log"
p := &SystemLog{
Meta: m,
Head: Head{
Kicker: "Server",
Title: "System log",
Lead: Rich("The tail of ", Code("mail.log"), " — Postfix, OpenDKIM and the milters. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."),
},
Log: Box{No: "01", Title: "Recent lines", ID: "system-log", Poll: serverLog + "/fragment",
End: Plain("Last 200 lines · refreshes on its own")},
Empty: EmptyState{Icon: "ti-file-text", Text: Plain("No log lines yet.")},
}
for i := len(lines) - 1; i >= 0; i-- {
p.Lines = append(p.Lines, lines[i])
}
if errText != "" {
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
}
return p
}
// ---- Backup
// Backup is the data of the backup page: the whole instance in one archive, and
// the import of one domain from another SelfPost. Both files are secrets and
// both can be sealed with a password, so the page carries the password fields of
// the full backup; the import asks for one only when the file needs it.
type Backup struct {
Meta
Head Head
Flash *Flash
Full Box
FullAction string
Encrypt bool
PasswordMin int
Import Box
ImportAction string
}
// NewBackup builds the page. The encryption box is ticked, as on the domain
// export: a secret file is sealed unless the operator chooses otherwise, and
// after a refusal the page comes back the same way. refusal is the message of
// whichever form was turned down, empty on a first visit.
func NewBackup(m Meta, passwordMin int, refusal string) *Backup {
m.Title, m.Section, m.Page = "Backup", "server", "backup"
p := &Backup{
Meta: m,
Head: Head{
Kicker: "Server",
Title: "Backup",
Lead: Plain("Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets."),
},
Full: Box{No: "01", Title: "Full backup"},
FullAction: serverBackup,
Encrypt: true,
PasswordMin: passwordMin,
Import: Box{No: "02", Title: "Import a domain", ID: "import"},
ImportAction: serverBackup + "/import",
}
if refusal != "" {
p.Flash = &Flash{Error: true, Text: Plain(refusal)}
}
return p
}
// ---- Users
// Users is the data of the list of panel users.
type Users struct {
Meta
Head Head
Flash *Flash
List Box
Rows []UserRow
ShowInbound bool
}
// UserRow is one panel user as the list shows them: the name, with a mark on
// the signed-in user's own row, the address, the role as a tag, the domains of
// each direction as words and the way to the form.
type UserRow struct {
Name string
You bool
Email string
Role Tag
Outbound string
Inbound string
Href string
}
// UserRowInput is what the list needs to know of one user. A global user
// reaches every domain, so the lists of names are only read for the domain role.
type UserRowInput struct {
ID int64
Username string
Email string
Global bool
You bool
AllOutbound bool
AllInbound bool
Outbound []string
Inbound []string
}
// NewUserRow words one user of the list.
func NewUserRow(in UserRowInput) UserRow {
role := Tag{Status: "domain"}
if in.Global {
role = Tag{Status: "global"}
}
return UserRow{
Name: in.Username, You: in.You, Email: orDash(in.Email), Role: role,
Outbound: reachWords(in.Global || in.AllOutbound, in.Outbound),
Inbound: reachWords(in.Global || in.AllInbound, in.Inbound),
Href: UserHref(in.ID),
}
}
// YouTag is the mark on the signed-in user's own row.
func (r UserRow) YouTag() Tag { return Tag{Label: "you"} }
// reachWords is what the list says of one direction of a user's reach: All, the
// names assigned, or a dash where nothing is.
func reachWords(all bool, names []string) string {
switch {
case all:
return "All"
case len(names) > 0:
return strings.Join(names, ", ")
}
return dash
}
// NewUsers builds the page. inbound is whether the inbound feature is on, which
// decides whether the list has the column and the lead speaks of it; flash is
// the result of the last action.
func NewUsers(m Meta, inbound bool, flash string) *Users {
m.Title, m.Section, m.Page = "Users", "server", "users"
reach := Rich("the domains assigned", Inline{Text: "."})
if inbound {
reach = Rich("the domains assigned, outbound and inbound separately", Inline{Text: "."})
}
p := &Users{
Meta: m,
Head: Head{
Kicker: "Server",
Title: "Users",
Lead: Rich("Who may sign in to this panel. Everyone here is an administrator; the role is the reach — ",
Em("global"), " is the whole server, ", Em("domain"), " is ", reach),
Actions: []Action{{Label: "Create user", Icon: "ti-user-plus", Href: serverUsers + "/new", Primary: true}},
},
List: Box{No: "01", Title: "Panel users"},
ShowInbound: inbound,
}
if flash != "" {
p.Flash = &Flash{Text: Plain(flash)}
}
return p
}
// WithRows sets the users and counts them in the box head.
func (p *Users) WithRows(rows []UserRow) *Users {
p.Rows = rows
p.List.End = Plain(plural(int64(len(rows)), "user", "users"))
return p
}
// ---- The user form
// UserForm is the data of the form that creates a panel user and edits one: the
// account in one box, and the domains the user reaches, outbound and inbound
// separately, in the other column. The same form serves both; a blank password
// on an existing user means keep the current one.
type UserForm struct {
Meta
Head Head
Flash *Flash
// Action is where the form is sent, Submit what its button says and
// CancelHref where Cancel goes.
Action string
Submit string
CancelHref string
Account Box
Username string
Email string
NewUser bool
PasswordMin int
Roles []Option
Role string
// RoleLocked is the only global user: the role cannot be changed, so the
// select is read-only and the role travels in a hidden field.
RoleLocked bool
Outbound Box
AllOut bool
OutDomains []DomainChoice
ShowInbound bool
Inbound Box
AllIn bool
InDomains []DomainChoice
}
// DomainChoice is one row of a list of domains on the user form.
type DomainChoice struct {
ID int64
Name string
Checked bool
}
// UserFormInput is what a user form shows: a new user's blanks or an existing
// user's values, or what was typed into a form that was refused.
type UserFormInput struct {
// ID is the user being edited, 0 for a new one; Name is that user's stored
// name, which the head shows whatever was typed into the form.
ID int64
Name string
Username string
Email string
Role string
RoleLocked bool
// CanDelete says whether the head offers Delete user.
CanDelete bool
PasswordMin int
ShowInbound bool
AllOut bool
OutDomains []DomainChoice
AllIn bool
InDomains []DomainChoice
Error string
}
// NewUserForm builds the page.
func NewUserForm(m Meta, in UserFormInput) *UserForm {
crumbs := []Crumb{{Href: serverUsers, Label: "Users"}}
title := "Create user"
if in.ID != 0 {
title = in.Name
}
m.Title, m.Section, m.Page = title+" · Users", "server", "users"
p := &UserForm{
Meta: m,
Head: Head{Crumbs: crumbs, Title: title},
CancelHref: serverUsers,
Account: Box{No: "01", Title: "Account"},
Username: in.Username,
Email: in.Email,
NewUser: in.ID == 0,
PasswordMin: in.PasswordMin,
Roles: []Option{
{Value: "domain", Label: "Domain — the domains assigned"},
{Value: "global", Label: "Global — the whole server"},
},
Role: in.Role,
RoleLocked: in.RoleLocked,
Outbound: Box{No: "02", Title: "Outbound domains", End: Plain("DNS records, applications, log"), DomainPick: true},
AllOut: in.AllOut,
OutDomains: in.OutDomains,
ShowInbound: in.ShowInbound,
Inbound: Box{No: "03", Title: "Inbound domains", End: Plain("Upstream, recipients, log"), DomainPick: true},
AllIn: in.AllIn,
InDomains: in.InDomains,
}
if p.NewUser {
p.Action, p.Submit = serverUsers+"/new", "Create user"
} else {
p.Action, p.Submit = UserHref(in.ID), "Save user"
if in.CanDelete {
p.Head.Actions = []Action{{Label: "Delete user", Icon: "ti-trash", Href: UserHref(in.ID) + "/delete", Danger: true}}
}
}
if in.Error != "" {
p.Flash = &Flash{Error: true, Text: Plain(in.Error)}
}
return p
}
// ---- Delete a user
// UserDelete is the data of the confirmation before a panel user is deleted:
// what goes with them, the one button that does it, and the way out.
type UserDelete struct {
Meta
Head Head
Danger Box
Intro Text
Consequences []Text
Note Text
Action string
Confirm string
KeepHref string
}
// UserDeleteInput is what the confirmation needs to know of the user.
type UserDeleteInput struct {
ID int64
Username string
Global bool
// What the user is assigned: All of a direction, or the names of its domains.
AllOutbound bool
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.
func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
m.Title, m.Section, m.Page = "Delete "+in.Username, "server", "users"
href := UserHref(in.ID)
p := &UserDelete{
Meta: m,
Head: Head{
Crumbs: []Crumb{{Href: serverUsers, Label: "Users"}, {Href: href, Label: in.Username}},
Title: "Delete " + in.Username,
},
Danger: Box{Icon: "ti-alert-triangle", Title: "This cannot be undone", Variant: BoxDanger},
Intro: Rich("Deleting the panel user ", Strong(in.Username), " will:"),
Consequences: []Text{
Plain("end any signed-in session of this user at once;"),
},
Note: Plain("Applications and their passwords, upstreams and recipients are not affected."),
Action: href + "/delete",
Confirm: "Delete " + in.Username,
KeepHref: href,
}
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)
}
var out, inb Text
switch {
case in.AllOutbound:
out = Text{Strong("all outbound domains")}
default:
out = names(in.Outbound)
}
switch {
case in.AllInbound:
inb = Text{Strong("all inbound domains")}
default:
inb = names(in.Inbound)
}
switch {
case len(out) > 0 && len(inb) > 0:
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", out, " (outbound) and to ", inb, " (inbound)."))
case len(out) > 0:
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", out, " (outbound)."))
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
}
// names is a run of domain names, each strong, separated by commas.
func names(list []string) Text {
var out Text
for i, n := range list {
if i > 0 {
out = append(out, Inline{Text: ", "})
}
out = append(out, Strong(n))
}
return out
}