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>
459 lines
13 KiB
Go
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
|
|
}
|