panel-redesign: paths follow the menu; Account and Server settings split

Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.

Routes (web.go)
  - /status -> /overview; /domains -> /outbound/domains with the domain's
    settings actions under .../settings/ (reports, ratelimit, export);
    /deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
    /outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
    /server/log; /backup -> /server/backup (import under it); /users ->
    /server/users; recheck and reload under /server/health/.
  - One fragment name: <page>/fragment everywhere.
  - Everything under /server/ is registered through server(), which wraps
    the handler in globalOnly: the subtree is guarded once, where a new
    route cannot forget it. Handlers keep their own requireGlobal.
  - GET / leads to /overview, /outbound/domains or /inbound/domains.
  - The /account -> /settings redirect is gone with /settings.

Account and Server > Settings
  - handlers_account.go: GET /account and three POSTs - profile (username,
    e-mail; no password asked, as designed), password (current password,
    rate-limited like a sign-in, signs out other sessions), dmarc (the
    user's default: hosted / account / custom / none, each validated).
    This replaces step 1's bridge through the old Settings form.
  - handlers_settings.go is the instance's page only: log retention and the
    read-only rate limits, global role.
  - One legacy template serves both pages by a flag until stage 2 gives each
    its own: a new legacy page cannot be added to the ratchet.

Still to come inside stage 1, and why @routes stays on the ratchet:
  - step 4: the application routes (one POST, under the domain);
  - step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.

Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).

Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 13/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 21:51:55 +03:00
co-authored by Claude Opus 5.5
parent 3b86f6c3b6
commit bd746c61d7
55 changed files with 1006 additions and 692 deletions
+4 -4
View File
@@ -141,22 +141,22 @@ func TestE2E(t *testing.T) {
})
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
resp, body, err := sc.panel.get("/status")
resp, body, err := sc.panel.get("/overview")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
if strings.Contains(body, `href="/inbound"`) {
if strings.Contains(body, `href="/inbound/domains"`) {
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
}
resp, _, err = sc.panel.get("/inbound")
resp, _, err = sc.panel.get("/inbound/domains")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 404 {
t.Fatalf("GET /inbound = %d, want 404 with inbound relay off", resp.StatusCode)
t.Fatalf("GET /inbound/domains = %d, want 404 with inbound relay off", resp.StatusCode)
}
})
+3 -3
View File
@@ -187,10 +187,10 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
}
resp, err := sc.panel.status()
if err != nil {
t.Fatalf("GET /status after restart: %v", err)
t.Fatalf("GET /overview after restart: %v", err)
}
if resp.Request.URL.Path != "/status" {
t.Fatalf("session did not survive restart: landed on %s instead of /status", resp.Request.URL.Path)
if resp.Request.URL.Path != "/overview" {
t.Fatalf("session did not survive restart: landed on %s instead of /overview", resp.Request.URL.Path)
}
}
+13 -13
View File
@@ -130,20 +130,20 @@ func (c *panelClient) login(username, password string) error {
if err != nil {
return err
}
if resp.Request.URL.Path != "/status" {
return fmt.Errorf("login did not land on /status (landed on %s): %s", resp.Request.URL.Path, body)
if resp.Request.URL.Path != "/overview" {
return fmt.Errorf("login did not land on /overview (landed on %s): %s", resp.Request.URL.Path, body)
}
return nil
}
// addDomain submits the add-domain form and returns its assigned id, read
// back from the redirect target /domains/{id}.
// back from the redirect target /outbound/domains/{id}.
func (c *panelClient) addDomain(name string) (string, error) {
resp, body, err := c.postForm("/domains", url.Values{"name": {name}})
resp, body, err := c.postForm("/outbound/domains", url.Values{"name": {name}})
if err != nil {
return "", err
}
m := regexp.MustCompile(`^/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
m := regexp.MustCompile(`^/outbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
if m == nil {
return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body)
}
@@ -153,7 +153,7 @@ func (c *panelClient) addDomain(name string) (string, error) {
// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells
// the administrator to publish.
func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) {
_, body, err := c.get("/domains/" + domainID)
_, body, err := c.get("/outbound/domains/" + domainID)
if err != nil {
return "", "", err
}
@@ -172,7 +172,7 @@ func (c *panelClient) dkimRecord(domainID string) (name, value string, err error
// login/password the panel renders inline (security.md — never recoverable
// later, so this is the only place to read it).
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
resp, body, err := c.postForm("/domains/"+domainID+"/applications", url.Values{
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications", url.Values{
"login": {login},
"mode": {mode},
"addresses": {addresses},
@@ -195,7 +195,7 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
}
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// or domain (/applications/{id}/ratelimit or /domains/{id}/ratelimit).
// or domain (/applications/{id}/ratelimit or /outbound/domains/{id}/settings/ratelimit).
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
vals := url.Values{
"max_messages": {fmt.Sprintf("%d", maxMessages)},
@@ -211,25 +211,25 @@ func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int)
return nil
}
// sendLogRows returns the raw /deliveries/rows HTML fragment, filtered to one
// sendLogRows returns the raw /outbound/log/fragment HTML fragment, filtered to one
// domain and optionally to one application login, for polling a row's status
// without parsing full HTML into structs. The application is a filter rather
// than something to search the returned rows for: the log's columns identify a
// message (time, from, to, subject, status) and the sending application is only
// named on a row's own /deliveries/{id} page.
// named on a row's own /outbound/log/{id} page.
func (c *panelClient) sendLogRows(domain, app string) (string, error) {
q := url.Values{"domain": {domain}}
if app != "" {
q.Set("app", app)
}
_, body, err := c.get("/deliveries/rows?" + q.Encode())
_, body, err := c.get("/outbound/log/fragment?" + q.Encode())
return body, err
}
// status fetches the authenticated landing page — used after a container
// restart to confirm the session cookie is still accepted (plan C.4 check 8).
func (c *panelClient) status() (*http.Response, error) {
resp, _, err := c.get("/status")
resp, _, err := c.get("/overview")
return resp, err
}
@@ -240,7 +240,7 @@ func (c *panelClient) status() (*http.Response, error) {
// reordering the block's controls does not break the scrape; only the login
// heading itself is anchored on.
func (c *panelClient) applicationID(domainID, login string) (string, error) {
_, body, err := c.get("/domains/" + domainID)
_, body, err := c.get("/outbound/domains/" + domainID)
if err != nil {
return "", err
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"}
// checkSupervisorProcesses shells into the container directly (not through the
// panel's /status page) so it works before an administrator account even
// panel's /overview page) so it works before an administrator account even
// exists — this is the first thing the harness checks after `docker compose
// up` (plan C.4). Programs take a moment to leave STARTING right after the
// container starts, so this polls rather than checking once.