Stage 2, step 3. Written by Sonnet, reviewed by Opus.
- Six pages named after their mockups: out-domains, out-domain,
out-domain-settings, out-app, out-app-created, out-domain-delete,
with typed page data (view/outbound.go, outbound_app.go), fixtures
and tests. The engine pages dashboard, domain_detail and
domain_delete are gone with their templates and are off
legacy_pages.txt. GET .../settings has its own handler.
- Handlers pass typed data; routes, POST targets, field names,
validation, authorization (both roles, another tenant's id is a 404)
and Cache-Control: no-store on the password page are unchanged.
- Kit, attribute only: Action.Confirm writes data-confirm on a POST
action's form (panel.js already reads it).
- test/e2e/panel_client.go scrapes the new markup (copy fields, the
application row's Edit link). Not run: no Docker here.
Fixes found on the way
- Report address of a domain that follows another user's default: the
select had one "inherit" option, selected, so saving the form
untouched moved the domain to whoever saved it. It now offers that
user's default as "keep" (selected, saves nothing) and "My default
instead" as the change, as the mockup draws it; "keep" on a domain
that does not follow someone else is refused.
- panel.js: syncShowWhen stopped at the first matching rule, so the
auto-limit fields were never toggled with the limit mode. Every
matching rule applies now.
Differences from the mockups, for the owner:
- application rows carry Edit only. New password and Delete are POSTs,
and the kit has no link-styled POST control for a table row; both are
in the application page's head with a confirmation. Drawing them in
the row needs a design: change (as Sign out was);
- the application login is read-only on edit (the handler refuses a
rename); the submit says Create application on the empty form;
- multiplier bounds are the store's 1.5-5, default 2.5;
- "Recalculate" buttons exist where the stored limit is automatic;
- a domain administrator has no add box and no Delete; Delete domain is
global-only on the domain page and on settings;
- a fourth record, Report authorization, appears when the report
address is on another domain; record states missing / weak / not
checked are tags with the explanation under the value;
- refusals are the kit flash above the first box, and those of the
settings forms now show on the settings page; successful saves still
redirect to the domain page;
- the export checkbox is ticked by default, as drawn.
Evidence
- docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
out-domain-settings.png, out-app.png, out-app-created.png,
out-domain-delete.png: 1440 px wide, headless Edge, real templates
served locally with their fixtures - NOT from the container (no
Docker on this machine). Taken before the report-address fix, which
does not change what the fixture renders.
- panel/audit.js on the six pages at 1440 and 1280 (text inside
[hidden] blocks skipped): 866 texts checked, problems: [].
- go test -count=1 ./internal/web/...
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
8.8 KiB
Go
264 lines
8.8 KiB
Go
package view
|
|
|
|
// The data of the application pages: the form that adds or edits an application
|
|
// (templates/out_app.html, mockup out-app.html) and the page that shows its
|
|
// password once (out_app_created.html, mockup out-app-created.html).
|
|
//
|
|
// The form is one POST: who the application may send as, which client IPs may
|
|
// use it and its rate limit are checked and saved together or not at all.
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Address modes of an application, the values of the sender select
|
|
// (store.AddressMode*).
|
|
const (
|
|
AddressWildcard = "wildcard"
|
|
AddressList = "list"
|
|
)
|
|
|
|
// Rate-limit choices of an application, the values of the limit select.
|
|
const (
|
|
LimitDomain = "domain"
|
|
LimitManual = "manual"
|
|
LimitAuto = "auto"
|
|
)
|
|
|
|
// OutApp is the data of the application form. It adds an application when
|
|
// Login is not fixed (the empty form) and edits one when it is. The form fields
|
|
// hold what was stored, or what was just submitted when the form is shown again
|
|
// with a refusal.
|
|
type OutApp struct {
|
|
Meta
|
|
Head Head
|
|
Flash *Flash
|
|
|
|
// Action is where the form is posted; Submit is the main button's label.
|
|
// Cancel leads back to the domain. Recalc, set only while the limit is
|
|
// automatic, is the POST address of its "Recalculate" button.
|
|
Action string
|
|
Submit string
|
|
Cancel string
|
|
Recalc string
|
|
|
|
Sender Box
|
|
Login string
|
|
LoginFixed bool
|
|
LoginHelp Text
|
|
Modes []Option
|
|
Mode string
|
|
Addresses string
|
|
Placeholder string
|
|
|
|
IPs Box
|
|
IPRestrict bool
|
|
AllowedIPs string
|
|
|
|
Limit Box
|
|
LimitModes []Option
|
|
LimitMode string
|
|
MaxMessages string
|
|
Window string
|
|
Multiplier string
|
|
Rate AppRateLimit
|
|
|
|
recalc string
|
|
}
|
|
|
|
// AppRateLimit is the context of an application's rate-limit fields: the
|
|
// ceiling nothing may exceed (level 1), the range of the multiplier, what the
|
|
// domain's own limit is, and what the automatic limit has computed.
|
|
type AppRateLimit struct {
|
|
L1Messages int
|
|
L1Window int
|
|
MinMultiplier string
|
|
MaxMultiplier string
|
|
DefaultMultiplier string
|
|
// DomainLimit is the limit the application falls back to ("288 / h"), ""
|
|
// when the domain has none of its own.
|
|
DomainLimit string
|
|
// Computed, Peak and Updated are the automatic limit as DomainRateLimit has
|
|
// them; Computed is "" for an application that has none yet.
|
|
Computed string
|
|
Peak int64
|
|
Updated string
|
|
}
|
|
|
|
// AutoHelp is the line under the multiplier: what the limit computes to now.
|
|
func (r AppRateLimit) AutoHelp() Text {
|
|
return autoLimitHelp(r.Computed, r.L1Window, r.Peak, r.Updated)
|
|
}
|
|
|
|
// OutAppState is what is stored about the application, which the tags in the
|
|
// box heads and the Recalculate button report (the fields may hold something
|
|
// that was not saved): whether its client IP allow-list is on, whether it has a
|
|
// rate limit of its own, and whether that limit is automatic.
|
|
type OutAppState struct {
|
|
IPs bool
|
|
Limit bool
|
|
Auto bool
|
|
}
|
|
|
|
// OutAppForm is the values of the application form.
|
|
type OutAppForm struct {
|
|
Login string
|
|
Mode string
|
|
Addresses string
|
|
IPRestrict bool
|
|
AllowedIPs string
|
|
LimitMode string
|
|
MaxMessages string
|
|
Window string
|
|
Multiplier string
|
|
}
|
|
|
|
// NewOutApp builds the form of a domain's application. login is the stored
|
|
// application's login, or "" for the form that adds one.
|
|
func NewOutApp(m Meta, domainID int64, domain, login string) *OutApp {
|
|
href := DomainHref(domainID)
|
|
crumbs := append(outboundCrumbs(domainID, domain), Crumb{Label: "Applications"})
|
|
m.Section, m.Page = "outbound", "domains"
|
|
p := &OutApp{
|
|
Cancel: href,
|
|
Sender: Box{No: "01", Title: "Sender"},
|
|
IPs: Box{No: "02", Title: "Client IP allow-list"},
|
|
Limit: Box{No: "03", Title: "Rate limit"},
|
|
Modes: []Option{
|
|
{Value: AddressWildcard, Label: "Any address of the domain"},
|
|
{Value: AddressList, Label: "Specific addresses (list)"},
|
|
},
|
|
LimitModes: []Option{
|
|
{Value: LimitDomain, Label: "Use the domain limit"},
|
|
{Value: LimitManual, Label: "Manual"},
|
|
{Value: LimitAuto, Label: "Auto (from statistics)"},
|
|
},
|
|
Placeholder: "alerts@" + domain,
|
|
}
|
|
if login == "" {
|
|
m.Title = "New application · " + domain
|
|
p.Head = Head{Crumbs: crumbs, Title: "New application",
|
|
Lead: Plain("A password is generated when it is created and shown once.")}
|
|
p.Action = href + "/applications/new"
|
|
p.Submit = "Create application"
|
|
p.LoginHelp = Rich("Unique across domains; letters, digits, ", Code("."), " ", Code("-"), " ", Code("_"), ".")
|
|
} else {
|
|
m.Title = login + " · " + domain
|
|
p.Head = Head{Crumbs: crumbs, Title: login, Mono: true}
|
|
p.Submit = "Save application"
|
|
p.LoginFixed = true
|
|
p.LoginHelp = Plain("Fixed: add a new application to use another login.")
|
|
}
|
|
p.Meta = m
|
|
return p
|
|
}
|
|
|
|
// WithApplication points an edit form at its application: the POST address, the
|
|
// head's buttons (a new password and delete, each asking first) and the mail of
|
|
// the statistics window under the title.
|
|
func (p *OutApp) WithApplication(domainID, id int64, total, peak int64, avg string, days int) *OutApp {
|
|
own := DomainHref(domainID) + "/applications/" + strconv.FormatInt(id, 10)
|
|
p.Action = own
|
|
p.Head.Lead = StatsLead(total, peak, avg, days)
|
|
p.Head.Actions = []Action{
|
|
{Label: "New password", Icon: "ti-key", Post: own + "/password",
|
|
Confirm: "Generate a new password for " + p.Head.Title + "? The current password stops working immediately."},
|
|
{Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true,
|
|
Confirm: "Delete application " + p.Head.Title + "? Its credentials stop working immediately."},
|
|
}
|
|
p.recalc = own + "/ratelimit/recalc"
|
|
return p
|
|
}
|
|
|
|
// WithForm fills the fields; state is what is stored.
|
|
func (p *OutApp) WithForm(f OutAppForm, state OutAppState, rate AppRateLimit) *OutApp {
|
|
p.Login, p.Mode, p.Addresses = f.Login, f.Mode, f.Addresses
|
|
p.IPRestrict, p.AllowedIPs = f.IPRestrict, f.AllowedIPs
|
|
p.LimitMode, p.MaxMessages, p.Window, p.Multiplier = f.LimitMode, f.MaxMessages, f.Window, f.Multiplier
|
|
p.Rate = rate
|
|
|
|
if state.IPs {
|
|
p.IPs.End = Rich(TagOf("ok", "active"))
|
|
} else {
|
|
p.IPs.End = Rich(TagOf("off", "off"))
|
|
}
|
|
if state.Limit {
|
|
p.Limit.End = Rich(TagOf("ok", "active"))
|
|
} else {
|
|
p.Limit.End = Rich(TagOf("off", "inactive"))
|
|
}
|
|
if state.Auto && p.recalc != "" {
|
|
p.Recalc = p.recalc
|
|
}
|
|
return p
|
|
}
|
|
|
|
// LimitHelp is the line under the rate-limit fields: what the limit overrides.
|
|
func (p *OutApp) LimitHelp() Text {
|
|
fall := "the domain limit, if it has one, or level 1"
|
|
if p.Rate.DomainLimit != "" {
|
|
fall = "the domain limit (" + p.Rate.DomainLimit + ")"
|
|
}
|
|
return Rich("Overrides ", fall, " for this application; may be higher or lower, never above level 1.")
|
|
}
|
|
|
|
// WithResult shows the refusal of the form, or the result of an action, between
|
|
// the head and the form.
|
|
func (p *OutApp) WithResult(flash, formErr string) *OutApp {
|
|
p.Flash = resultFlash(flash, formErr)
|
|
return p
|
|
}
|
|
|
|
// ---- The password, shown once
|
|
|
|
// OutAppCreated is the page that follows creating an application or issuing it
|
|
// a new password: the login and the password, which are not stored and are not
|
|
// shown again, and how to connect with them. It is the response to a POST and
|
|
// has no address of its own.
|
|
type OutAppCreated struct {
|
|
Meta
|
|
Head Head
|
|
|
|
Credential Box
|
|
Login CopyField
|
|
Password CopyField
|
|
Done Foot
|
|
|
|
Connect Box
|
|
Facts []Fact
|
|
}
|
|
|
|
// NewOutAppCreated builds the page. fresh says the application was created just
|
|
// now (it had no password before). host is the server's name, submission whether
|
|
// port 587 is on, and senders what the login may send as.
|
|
func NewOutAppCreated(m Meta, domainID int64, domain, login, password string, fresh bool, host string, submission bool, senders []string) *OutAppCreated {
|
|
m.Title, m.Section, m.Page = "New password · "+login, "outbound", "domains"
|
|
help := Plain("The previous password stopped working when this one was generated. If this one is lost, generate another.")
|
|
if fresh {
|
|
help = Plain("If this one is lost, generate another from the application's page.")
|
|
}
|
|
if host == "" {
|
|
host = "(SELFPOST_HOSTNAME is not set)"
|
|
}
|
|
port := "465 SSL/TLS"
|
|
if submission {
|
|
port += " · 587 STARTTLS"
|
|
}
|
|
return &OutAppCreated{
|
|
Meta: m,
|
|
Head: Head{Crumbs: append(outboundCrumbs(domainID, domain), Crumb{Label: "Applications"}),
|
|
Title: "New password for", Code: login},
|
|
Credential: Box{Icon: "ti-eye", Title: "Shown once", End: Plain("Not stored — copy it now"), Variant: BoxCredential},
|
|
Login: CopyField{Label: "Login", Value: login},
|
|
Password: CopyField{Label: "Password", Value: password, Help: help},
|
|
Done: Foot{Button: &Action{Label: "Done — back to " + domain, Href: DomainHref(domainID), Primary: true}},
|
|
Connect: Box{Icon: "ti-plug", Title: "Connect with"},
|
|
Facts: []Fact{
|
|
{Label: "Server", Value: Plain(host), Mono: true},
|
|
{Label: "Port", Value: Plain(port)},
|
|
{Label: "May send as", Value: Plain(strings.Join(senders, ", ")), Mono: true},
|
|
},
|
|
}
|
|
}
|