Files
selfpost/internal/web/view/server.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

459 lines
13 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")},
AllOut: in.AllOut,
OutDomains: in.OutDomains,
ShowInbound: in.ShowInbound,
Inbound: Box{No: "03", Title: "Inbound domains", End: Plain("Upstream, recipients, log")},
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
}