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>
423 lines
12 KiB
Go
423 lines
12 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", Help: topicLink(HelpBackup, "Backups, encryption and restore")},
|
|
FullAction: serverBackup,
|
|
Encrypt: true,
|
|
PasswordMin: passwordMin,
|
|
Import: Box{No: "02", Title: "Import a domain", ID: "import", Help: topicLink(HelpBackup, "Moving a domain to another instance")},
|
|
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", Help: topicLink(HelpUsers, "Roles are a reach, not a rank")},
|
|
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", Help: topicLink(HelpUsers, "Roles and domain assignments")},
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|