carlos / platform Public

Merge branch 'console-ux-02-settings' into main

paul@keymail.dev f33f3404ee0dcb502080d0487729226c33a65984 parent cff5c82parent 175d521
18 files changed, +382 −80
  • internal/console/analyticsview.go +1 −1
  • internal/console/apifeatures_test.go +2 −2
  • internal/console/appdomains.go +8 −3
  • internal/console/appdomains_test.go +4 −4
  • internal/console/appsettings.go +51 −12
  • internal/console/appsettings_gate_test.go +4 −4
  • internal/console/appsettings_sections_test.go +120 −0
  • internal/console/appsettings_test.go +28 −21
  • internal/console/controlplane.go +3 −4
  • internal/console/controlplane_test.go +12 −12
  • internal/console/errorsview.go +1 −1
  • internal/console/pipelinepage.go +7 −2
  • internal/console/server.go +66 −2
  • internal/console/server_test.go +8 −4
  • internal/console/templates/app_overview.html +1 −1
  • internal/console/templates/app_settings.html +38 −7
  • internal/console/templates/layout.html +21 −0
  • internal/console/templates/pipeline.html +7 −0

CI passed — run details

diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go
index f9892dc57..8a596d72c 100644
--- a/internal/console/analyticsview.go
+++ b/internal/console/analyticsview.go
@@ -95,7 +95,7 @@ func (s *Server) handleAppAnalyticsSetting(w http.ResponseWriter, r *http.Reques
return
}
count, door, bad := analyticsFormValues(r)
- back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ back := appSettingsURL(acct.Sqid, app, "general")
// The setting lives in the deployment bucket, so a console without one
// has nowhere to put it — refused before the write rather than panicking
// inside it, exactly as the errors level is.
diff --git a/internal/console/apifeatures_test.go b/internal/console/apifeatures_test.go
index da10dcb2f..0b60c91be 100644
--- a/internal/console/apifeatures_test.go
+++ b/internal/console/apifeatures_test.go
@@ -276,7 +276,7 @@ func TestSettingsPageShowsFeatureFlags(t *testing.T) {
t.Fatal(err)
}
- status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings/features"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -299,7 +299,7 @@ func TestSettingsPageShowsFeatureFlags(t *testing.T) {
// An app with none set says so, and still tells the member how one
// gets set.
freshApp(t, fx, "sprout")
- _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings/features"))
if !strings.Contains(body, "No feature flags") || !strings.Contains(body, "carlos features set --app sprout") {
t.Fatalf("an app with no flags is not told so:\n%s", body)
}
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go
index ec23703c9..eee8eaa97 100644
--- a/internal/console/appdomains.go
+++ b/internal/console/appdomains.go
@@ -17,7 +17,6 @@ import (
"context"
"crypto/subtle"
"net/http"
- "net/url"
"strings"
"time"
)
@@ -47,6 +46,12 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
defer cancel()
res, ref := s.attachDomain(ctx, acct, app, host, "", channel, addr)
if ref != nil {
+ if ref.Status == http.StatusUnprocessableEntity {
+ s.renderAppSettingsForm(w, r, ref.Status, "domains", ref.Msg, map[string]string{
+ "domain": host, "channel": channel,
+ })
+ return
+ }
s.renderDoorRefusal(w, r, ref)
return
}
@@ -55,7 +60,7 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
msg = "Attached " + host + " as the app's first route, following " + res.Channel + "."
}
s.setFlash(w, "ok", msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
}
// handleAppDomainDetach releases a host's claim. The typed-host confirm is
@@ -88,7 +93,7 @@ func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
return
}
s.setFlash(w, "ok", "Detached "+host+".")
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
}
// renderDoorRefusal turns a doorRefusal from the Task 3 core into this
diff --git a/internal/console/appdomains_test.go b/internal/console/appdomains_test.go
index 2ddc2a2f0..522b74069 100644
--- a/internal/console/appdomains_test.go
+++ b/internal/console/appdomains_test.go
@@ -30,7 +30,7 @@ func TestAppDomainAttachHappyPathAliases(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
url.Values{"csrf": {csrf}, "host": {host}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
claim, err := domains.GetClaim(context.Background(), fx.rel, host)
@@ -74,7 +74,7 @@ func TestAppDomainAttachLowercasesHost(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
url.Values{"csrf": {csrf}, "host": {mixed}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
if _, err := domains.GetClaim(context.Background(), fx.rel, lower); err != nil {
@@ -156,7 +156,7 @@ func TestAppDomainDetachHappyPath(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
claimAbsent(t, fx, host)
@@ -290,7 +290,7 @@ func TestAppDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T)
st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
url.Values{"csrf": {csrf}, "host": {"mine.example.com"}})
- if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("re-attach of the caller's own hostname = %d -> %q, want 303 -> settings:\n%s", st, loc, body)
}
}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 6e7d2c3d0..b4a7568c8 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -46,8 +46,34 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// appSettingsSections is the closed set accepted after /settings/. Keeping
+// this server-side means a typo gets the same 404 as any other unknown app
+// page, rather than rendering an empty settings shell.
+var appSettingsSections = map[string]bool{
+ "general": true,
+ "domains": true,
+ "pipeline": true,
+ "storage": true,
+ "email": true,
+ "features": true,
+ "advanced": true,
+ "delete": true,
+}
+
+func appSettingsURL(acct, app, section string) string {
+ base := "/a/" + url.PathEscape(acct) + "/apps/" + url.PathEscape(app) + "/settings"
+ if section == "" || section == "general" {
+ return base
+ }
+ return base + "/" + section
+}
+
// SettingsView is what the Settings tab knows beyond the shared AppView.
type SettingsView struct {
+ // NoBucket distinguishes a console deliberately running without a
+ // deployment store from a transient read failure.
+ NoBucket bool
+
// CanRename: nothing outside the console database knows this name yet.
// RenameBlocked carries the reason when it is false.
CanRename bool
@@ -76,19 +102,25 @@ type SettingsView struct {
// and a zero card when there is genuinely no store, which is what the
// "ask for one" line renders from.
Store *StoreCard
+ // StoreUnavailable distinguishes a failed read from a genuinely absent
+ // record, so the section remains present and does not claim there is no
+ // store during a transient failure.
+ StoreUnavailable bool
// Mail is the app's tenant email as this page sees it (issue #240) —
// object storage's sibling primitive, same declare -> provision ->
// deliver -> rotate shape, and the same error discipline as Store: nil
// on a read failure, a zero card when there is genuinely no record.
- Mail *MailCard
+ Mail *MailCard
+ MailUnavailable bool
// Features is the app's feature flags as the platform serves them
// (#347, apifeatures.go) — read-only here; the write is the audited,
// admin-gated CLI door. Same error discipline as Store: nil on a read
// failure (the page says nothing rather than "none set" about a record
// it could not parse), a zero card when there genuinely are none.
- Features *FeaturesCard
+ Features *FeaturesCard
+ FeaturesUnavailable bool
// Domains is this page's OWN Domains & TLS rows (settingsDomains): a
// copy of AppView.Domains with a placeholder row appended for any of
@@ -257,6 +289,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
}
sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName)
if s.cfg.Releases == nil {
+ sv.NoBucket = true
sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run."
return sv
}
@@ -273,6 +306,9 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
sv.Store = s.storeCard(ctx, acct, p.AppName)
sv.Mail = s.mailCard(ctx, acct, p.AppName)
sv.Features = s.featuresCard(ctx, acct, p.AppName)
+ sv.StoreUnavailable = sv.Store == nil
+ sv.MailUnavailable = sv.Mail == nil
+ sv.FeaturesUnavailable = sv.Features == nil
if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
@@ -553,7 +589,7 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther)
}
// The control plane keeps its name (controlplane.go): the fleet's release
// keys, its channels and the units that exec them are all written in it.
@@ -569,15 +605,18 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
// the mux prefers the literal, so no console page could ever show it.
to := normalizeAppName(r.FormValue("to"))
if !release.ValidApp(to) {
- back("err", "App names are lowercase letters, digits and dashes.")
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
+ "App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")})
return
}
if to == "new" {
- back("err", "That name is reserved.")
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
+ "That name is reserved.", map[string]string{"rename": r.FormValue("to")})
return
}
if to == app {
- back("err", "That is already this app's name.")
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
+ "That is already this app's name.", map[string]string{"rename": r.FormValue("to")})
return
}
if s.cfg.Releases == nil {
@@ -652,7 +691,7 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
}
s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to)
s.setFlash(w, "ok", "Renamed "+app+" to "+to+".")
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(to)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, to, "general"), http.StatusSeeOther)
}
// renameAdvice maps RenameAppIn's refusals onto the sentence a person can
@@ -688,7 +727,7 @@ func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings",
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"),
http.StatusSeeOther)
}
if s.cfg.Releases == nil {
@@ -777,7 +816,7 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "delete"), http.StatusSeeOther)
}
// Outright, before the typed name and long before the passkey: the flag
// is the guard, and no gesture answers it (controlplane.go).
@@ -901,7 +940,7 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
// ownership gate and is entitled to know what went wrong.
fail := func(msg string) {
s.setFlash(w, "err", msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther)
}
// The sixth destructive door, and the one whose ceremony is closest to
// enough without being it: owner, CSRF, the typed BUCKET name and a
@@ -998,7 +1037,7 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
// bump that stands until the hourly floor comes round.
s.bumpSerial(ctx)
s.setFlash(w, "ok", flash)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther)
}
// storeConfirmWord is what a person must type to destroy a store: the BUCKET
@@ -1039,7 +1078,7 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther)
}
// The control plane does not change accounts: its releases are read from
// one account's prefix by every box in the fleet (controlplane.go).
diff --git a/internal/console/appsettings_gate_test.go b/internal/console/appsettings_gate_test.go
index b03ddcd34..340d742a2 100644
--- a/internal/console/appsettings_gate_test.go
+++ b/internal/console/appsettings_gate_test.go
@@ -22,14 +22,14 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
csrf := sessionToken(t, fx, c)
// The page shows the host as open, with a form.
- _, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains"))
if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") {
t.Fatalf("settings page lacks the gate section: %s", body)
}
status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"),
url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") {
t.Fatalf("set = %d -> %q", status, loc)
}
rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
@@ -39,7 +39,7 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
if !strings.HasPrefix(rec.Gate, "$2a$10$") {
t.Fatalf("stored gate = %q", rec.Gate)
}
- _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings/domains"))
if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") {
t.Fatalf("after set: %s", body)
}
@@ -50,7 +50,7 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
// A short password is refused with the rule, and nothing changes.
postForm(t, c, fx.u("/apps/titogo/gate"),
url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}})
- _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings/domains"))
if !strings.Contains(body, "at least 8 characters") {
t.Fatalf("short password not refused on the page: %s", body)
}
diff --git a/internal/console/appsettings_sections_test.go b/internal/console/appsettings_sections_test.go
new file mode 100644
index 000000000..2e7369847
--- /dev/null
+++ b/internal/console/appsettings_sections_test.go
@@ -0,0 +1,120 @@
+package console
+
+import (
+ "bytes"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestAppSettingsSectionRoutes(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-sections")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-sections-browser")
+
+ for _, section := range []string{"general", "domains", "storage", "email", "features", "advanced", "delete"} {
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings/"+section))
+ if status != http.StatusOK {
+ t.Errorf("GET settings/%s = %d, want 200: %s", section, status, body)
+ }
+ }
+ _, _, general := get(t, c, fx.u("/apps/titogo/settings"))
+ for _, want := range []string{
+ `aria-label="Settings sections"`, ">General</a>", ">Domains</a>",
+ ">Release pipeline</a>", ">Storage</a>", ">Email</a>",
+ ">Feature flags</a>", ">Advanced</a>", ">Delete</a>",
+ } {
+ if !strings.Contains(general, want) {
+ t.Errorf("Settings navigation missing %q", want)
+ }
+ }
+ if strings.Contains(general, "Domains &amp; TLS") {
+ t.Fatal("General still renders the Domains section")
+ }
+ _, _, domains := get(t, c, fx.u("/apps/titogo/settings/domains"))
+ if !strings.Contains(domains, "Domains &amp; TLS") || strings.Contains(domains, "<h2>Identity</h2>") {
+ t.Fatal("Domains does not render as an isolated section")
+ }
+ status, _, _ := get(t, c, fx.u("/apps/titogo/settings/not-a-section"))
+ if status != http.StatusNotFound {
+ t.Errorf("GET unknown settings section = %d, want 404", status)
+ }
+ assertNonMember404(t, fx, "/apps/titogo/settings/domains")
+}
+
+func TestAppSettingsURL(t *testing.T) {
+ if got := appSettingsURL("a b", "an/app", "general"); got != "/a/a%20b/apps/an%2Fapp/settings" {
+ t.Errorf("general URL = %q", got)
+ }
+ if got := appSettingsURL("bab", "kass", "storage"); got != "/a/bab/apps/kass/settings/storage" {
+ t.Errorf("storage URL = %q", got)
+ }
+}
+
+func TestPipelineSettingsCanonicalSectionURL(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-pipeline-section")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-pipeline-section-browser")
+
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings/pipeline"))
+ if status != http.StatusOK || !strings.Contains(body, "Release pipeline") {
+ t.Fatalf("GET settings/pipeline = %d: %s", status, body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/pipeline"))
+ if status != http.StatusOK || !strings.Contains(body, "Release pipeline") {
+ t.Fatalf("legacy GET pipeline = %d: %s", status, body)
+ }
+ csrf := sessionToken(t, fx, c)
+ status, _, body = postForm(t, c, fx.u("/apps/titogo/pipeline"), url.Values{
+ "csrf": {csrf}, "action": {"add"}, "ch.0.name": {"edge"},
+ })
+ if status != http.StatusOK || !strings.Contains(body, `aria-label="Settings sections"`) ||
+ !strings.Contains(body, `aria-current="page">Release pipeline</a>`) {
+ t.Fatalf("pipeline draft rerender lost Settings navigation = %d: %s", status, body)
+ }
+}
+
+func TestSettingsValidationRetainsTypedValues(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"), url.Values{
+ "csrf": {csrf}, "to": {"Still Typing"},
+ })
+ if status != http.StatusUnprocessableEntity || !strings.Contains(body, `value="Still Typing"`) {
+ t.Fatalf("rename refusal did not retain the typed name = %d: %s", status, body)
+ }
+
+ status, _, body = postForm(t, c, fx.u("/apps/sprout/domains/attach"), url.Values{
+ "csrf": {csrf}, "host": {"not a host"}, "channel": {"canary/draft"},
+ })
+ if status != http.StatusUnprocessableEntity || !strings.Contains(body, `value="not a host"`) ||
+ !strings.Contains(body, `value="canary/draft"`) {
+ t.Fatalf("domain refusal did not retain host and channel = %d: %s", status, body)
+ }
+}
+
+func TestBucketlessSettingsSectionsExplainConfiguration(t *testing.T) {
+ for section, want := range map[string]string{
+ "storage": "no deployment bucket on this host — this console cannot serve object storage",
+ "email": "no deployment bucket on this host — this console cannot serve email",
+ "features": "no deployment bucket on this host — this console cannot read features",
+ } {
+ p := page{
+ Title: "Settings", AppName: "sprout", ActiveTab: "settings", SettingsSection: section,
+ Account: &Account{Name: "paul@example.com", Sqid: "bab"},
+ Detail: &AppView{App: "sprout"}, Settings: &SettingsView{NoBucket: true},
+ }
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "app_settings", p); err != nil {
+ t.Fatalf("render %s: %v", section, err)
+ }
+ body := buf.String()
+ if !strings.Contains(body, want) || strings.Contains(body, "Please try again") {
+ t.Fatalf("bucketless %s section is not explicit: %s", section, body)
+ }
+ }
+}
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index e314c2cb3..b8f44ebcb 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -355,7 +355,7 @@ func TestAppDeleteRequiresTheExactName(t *testing.T) {
if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body)
}
- if loc != fx.path("/apps/kass/settings") {
+ if loc != fx.path("/apps/kass/settings/delete") {
t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc)
}
// The apostrophe in "app's" is HTML-escaped on the way out, so the
@@ -427,7 +427,7 @@ func TestAppDeleteRefusesAProductionApp(t *testing.T) {
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"),
url.Values{"csrf": {csrf}, "confirm": {"kass"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/delete") {
t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body)
}
if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is marked as production, so deleting is refused") {
@@ -692,7 +692,8 @@ func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
// pass vacuously: both forms ARE eligible to render in this exact state.
owner := signInPaul(t, fx)
_, _, obody := get(t, owner, fx.u("/apps/sprout/settings"))
- if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(obody, `id="delete-confirm"`) {
+ _, _, odelete := get(t, owner, fx.u("/apps/sprout/settings/delete"))
+ if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(odelete, `id="delete-confirm"`) {
t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody)
}
@@ -706,14 +707,16 @@ func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("member settings page = %d, want 200:\n%s", status, body)
}
- if !strings.Contains(body, "Domains") {
+ _, _, domainsBody := get(t, cm, fx.u("/apps/sprout/settings/domains"))
+ if !strings.Contains(domainsBody, "Domains &amp; TLS") {
t.Fatalf("a member cannot see the page at all:\n%s", body)
}
if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") {
t.Errorf("a member is offered the rename form:\n%s", body)
}
- if strings.Contains(body, `id="delete-confirm"`) || strings.Contains(body, "/delete\"") {
- t.Errorf("a member is offered the delete form:\n%s", body)
+ _, _, deleteBody := get(t, cm, fx.u("/apps/sprout/settings/delete"))
+ if strings.Contains(deleteBody, `id="delete-confirm"`) || strings.Contains(deleteBody, `action="/a/bab/apps/sprout/delete"`) {
+ t.Errorf("a member is offered the delete form:\n%s", deleteBody)
}
}
@@ -767,7 +770,7 @@ func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) {
if strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("confirm %q demanded a passkey before it was even checked (-> %q)", typed, loc)
}
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("confirm %q = %d -> %q, want the settings flash:\n%s", typed, status, loc, body)
}
if n := stashedActions(t, fx); n != 0 {
@@ -778,7 +781,7 @@ func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) {
t.Fatalf("a mistyped destroy touched the record: %+v", rec)
}
}
- if _, _, page := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(page, bucket) {
+ if _, _, page := get(t, c, fx.u("/apps/kass/settings/storage")); !strings.Contains(page, bucket) {
t.Fatalf("the refusal does not tell the owner what to type:\n%s", page)
}
}
@@ -804,7 +807,7 @@ func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) {
estateStanding(t, fx, fake, "kass", bucket, "a destroy awaiting its passkey")
status, loc, body := completeStepUp(t, fx, c, loc)
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("resumed destroy = %d -> %q:\n%s", status, loc, body)
}
estateGone(t, fake, "a completed destroy")
@@ -868,7 +871,7 @@ func TestStoreDestroyLeavesTheEmailEstateAlone(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {bucket}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("store destroy = %d -> %q:\n%s", status, loc, body)
}
estateGone(t, fake, "a store destroy that must leave email alone")
@@ -896,7 +899,7 @@ func TestStoreDestroyOfADeclarationNeedsNoBackend(t *testing.T) {
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {"kass"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("destroy of a declaration = %d -> %q:\n%s", status, loc, body)
}
if rec := storedRecord(t, fx, "kass"); rec != nil {
@@ -919,7 +922,7 @@ func TestStoreDestroyRefusesAnEstateWithNoBackend(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {bucket}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("backend-less destroy = %d -> %q:\n%s", status, loc, body)
}
if n := stashedActions(t, fx); n != 0 {
@@ -949,7 +952,7 @@ func TestStoreDestroyFailureLeavesTheRecordNamingTheEstate(t *testing.T) {
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {bucket}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("failed destroy = %d -> %q:\n%s", status, loc, body)
}
estateStanding(t, fx, fake, "kass", bucket, "a failed destroy")
@@ -1001,7 +1004,7 @@ func TestSettingsPageShowsTheStoreCard(t *testing.T) {
keyID := storedRecord(t, fx, "kass").Provisioned.KeyID
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/storage"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1024,7 +1027,7 @@ func TestSettingsPageShowsTheStoreCard(t *testing.T) {
// An app nobody has declared a store for gets the one line that tells them
// how to ask for one — and no destroy form for a store that isn't there.
freshApp(t, fx, "sprout")
- _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings/storage"))
if !strings.Contains(body, "carlos store create") {
t.Fatalf("an undeclared app is not told how to ask for a store:\n%s", body)
}
@@ -1051,7 +1054,7 @@ func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) {
freshApp(t, fx, "sprout")
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1072,7 +1075,7 @@ func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) {
const host = "docs.example.com"
fx, c, _ := domainAttachedFixture(t, "kass", host)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/domains"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1098,7 +1101,7 @@ func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) {
App: "sprout", Channel: "stable", Platform: true})
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1134,6 +1137,10 @@ func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) {
p := &page{AppName: "kass", Detail: detail}
sv := fx.srv.assembleSettings(context.Background(), p, acct)
+ if !sv.NoBucket || sv.StoreUnavailable || sv.MailUnavailable || sv.FeaturesUnavailable {
+ t.Fatalf("bucketless settings flags = NoBucket %v, unavailable %v/%v/%v", sv.NoBucket,
+ sv.StoreUnavailable, sv.MailUnavailable, sv.FeaturesUnavailable)
+ }
if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" {
t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains)
}
@@ -1390,7 +1397,7 @@ func TestSettingsPageShowsTheEmailCard(t *testing.T) {
})
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/email"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1412,7 +1419,7 @@ func TestSettingsPageShowsTheEmailCard(t *testing.T) {
// An app nobody has declared email for, on a console WITH a provisioner,
// gets the command that can actually run.
freshApp(t, fx, "sprout")
- _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings/email"))
if !strings.Contains(body, "carlos email enable --app sprout") {
t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body)
}
@@ -1428,7 +1435,7 @@ func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) {
fx.srv.cfg.MailProvisioner = nil
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/email"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go
index 2d2cf265b..2a097d02e 100644
--- a/internal/console/controlplane.go
+++ b/internal/console/controlplane.go
@@ -34,7 +34,6 @@ import (
"fmt"
"log"
"net/http"
- "net/url"
"runtime/debug"
"strings"
)
@@ -77,7 +76,7 @@ func (s *Server) controlPlaneRefusal(acct *Account, app, verb string) (msg strin
// caller has no page to be already looking at, and a browser flash pointing at
// an absolute URL costs nothing.
func (s *Server) appSettingsURL(acct *Account, app string) string {
- return s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ return s.cfg.Origin + appSettingsURL(acct.Sqid, app, "advanced")
}
// refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the
@@ -219,7 +218,7 @@ func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Reque
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther)
}
// Fail CLOSED on an unreadable row, like every other reader of this flag:
// clearing what may already be clear is harmless, but a lookup error here
@@ -274,7 +273,7 @@ func (s *Server) handleAppControlPlaneSet(w http.ResponseWriter, r *http.Request
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther)
}
cp, _, err := s.store.AppFlags(acct.ID, app)
if err != nil {
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index ad3780db6..9e13d9fb4 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -55,7 +55,7 @@ func TestControlPlaneAppRefusesTrash(t *testing.T) {
// And WHERE to clear it. A CLI caller has no page in front of them, so a
// refusal that names the flag without naming the card strands exactly the
// operator it just stopped.
- if !strings.Contains(msg, "/a/bab/apps/console/settings") {
+ if !strings.Contains(msg, "/a/bab/apps/console/settings/advanced") {
t.Fatalf("refusal %q does not point at the settings card that can clear the flag", msg)
}
present, err := fx.st.HasAppIn(OpsAccountID, "console")
@@ -343,7 +343,7 @@ func TestOwnerClearsTheControlPlaneFlag(t *testing.T) {
csrf := sessionToken(t, fx, c)
// The page has to OFFER it, or the door might as well not exist.
- if _, _, page := get(t, c, fx.u("/apps/console/settings")); !strings.Contains(page, "/apps/console/control-plane/clear") {
+ if _, _, page := get(t, c, fx.u("/apps/console/settings/advanced")); !strings.Contains(page, "/apps/console/control-plane/clear") {
t.Fatalf("the settings page offers no way to clear the flag:\n%s", page)
}
status, loc, body := postGated(t, fx, c, fx.u("/apps/console/control-plane/clear"),
@@ -356,7 +356,7 @@ func TestOwnerClearsTheControlPlaneFlag(t *testing.T) {
t.Fatalf("AppFlags after a confirmed clear = %v %v, want false", cp, err)
}
// The card is state-driven: with the flag gone, so is the button.
- if _, _, page := get(t, c, fx.u("/apps/console/settings")); strings.Contains(page, "/apps/console/control-plane/clear") {
+ if _, _, page := get(t, c, fx.u("/apps/console/settings/advanced")); strings.Contains(page, "/apps/console/control-plane/clear") {
t.Fatalf("the clear form is still offered on an app that is no longer flagged:\n%s", page)
}
// And the verb that was refused now works — the whole point of the door.
@@ -439,7 +439,7 @@ func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- if _, _, page := get(t, c, fx.u("/apps/titogo/settings")); !strings.Contains(page, "/apps/titogo/control-plane/set") {
+ if _, _, page := get(t, c, fx.u("/apps/titogo/settings/advanced")); !strings.Contains(page, "/apps/titogo/control-plane/set") {
t.Fatalf("the settings page offers no way to set the flag:\n%s", page)
}
status, loc, body := postForm(t, c, fx.u("/apps/titogo/control-plane/set"),
@@ -447,7 +447,7 @@ func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(t *testing.T) {
if status != http.StatusSeeOther {
t.Fatalf("set the flag = %d -> %q:\n%s", status, loc, body)
}
- if loc != fx.path("/apps/titogo/settings") {
+ if loc != fx.path("/apps/titogo/settings/advanced") {
t.Fatalf("set redirected to %q, want the settings tab — a Keymail redirect means setting grew a passkey it must not have", loc)
}
if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
@@ -492,7 +492,7 @@ func TestMemberCannotSetTheControlPlaneFlag(t *testing.T) {
signUp(t, fx, owner, "code-cp-set-owner-probe")
status, loc, _ = postForm(t, owner, fx.u("/apps/titogo/control-plane/set"),
url.Values{"csrf": {sessionToken(t, fx, owner)}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/advanced") {
t.Fatalf("the OWNER on the same URL = %d -> %q, want the handler's redirect — the member's 404 may just be a missing route", status, loc)
}
if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
@@ -519,7 +519,7 @@ func TestControlPlaneAppRefusesStoreDestroy(t *testing.T) {
if status != http.StatusSeeOther {
t.Fatalf("destroy a control-plane app's store = %d -> %q:\n%s", status, loc, body)
}
- if loc != fx.path("/apps/console/settings") {
+ if loc != fx.path("/apps/console/settings/storage") {
t.Fatalf("refusal redirected to %q, want the settings tab — a Keymail redirect means the guard ran too late", loc)
}
if rec := storedRecord(t, fx, "console"); rec == nil {
@@ -531,7 +531,7 @@ func TestControlPlaneAppRefusesStoreDestroy(t *testing.T) {
}
status, loc, body = postGated(t, fx, c, fx.u("/apps/console/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {"console"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/console/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/console/settings/storage") {
t.Fatalf("destroy after clearing the flag = %d -> %q:\n%s", status, loc, body)
}
if rec := storedRecord(t, fx, "console"); rec != nil {
@@ -925,7 +925,7 @@ func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) {
if !strings.Contains(out, "control plane") {
t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
}
- if !strings.Contains(out, "/a/bab/apps/console/settings") {
+ if !strings.Contains(out, "/a/bab/apps/console/settings/advanced") {
t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
}
// The record survived. This is the assertion the door exists for: the
@@ -1128,7 +1128,7 @@ func TestControlPlaneAppRefusesDomainDetach(t *testing.T) {
if !strings.Contains(out, "control plane") {
t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
}
- if !strings.Contains(out, "/a/bab/apps/kass/settings") {
+ if !strings.Contains(out, "/a/bab/apps/kass/settings/advanced") {
t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
}
claimOf(t, fx, host)
@@ -1181,7 +1181,7 @@ func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
if !strings.Contains(body, "control plane") {
t.Fatalf("browser refusal does not name the flag:\n%s", body)
}
- if !strings.Contains(body, "/a/bab/apps/kass/settings") {
+ if !strings.Contains(body, "/a/bab/apps/kass/settings/advanced") {
t.Fatalf("browser refusal does not point at the settings card that can clear the flag:\n%s", body)
}
claimOf(t, fx, host)
@@ -1192,7 +1192,7 @@ func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
}
if status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}); status != http.StatusSeeOther ||
- loc != fx.path("/apps/kass/settings") {
+ loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("browser detach once the flag is cleared = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
claimAbsent(t, fx, host)
diff --git a/internal/console/errorsview.go b/internal/console/errorsview.go
index 7eb57ed32..4eea044a0 100644
--- a/internal/console/errorsview.go
+++ b/internal/console/errorsview.go
@@ -228,7 +228,7 @@ func (s *Server) handleAppErrorLevel(w http.ResponseWriter, r *http.Request) {
return
}
level := r.FormValue("level")
- back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ back := appSettingsURL(acct.Sqid, app, "general")
// The setting lives in the deployment bucket, so a console without one
// has nowhere to put it. Refused before the write rather than panicking
// inside it, the way the rename door refuses its own bucket check.
diff --git a/internal/console/pipelinepage.go b/internal/console/pipelinepage.go
index 06be92230..371f873b3 100644
--- a/internal/console/pipelinepage.go
+++ b/internal/console/pipelinepage.go
@@ -21,7 +21,6 @@ import (
"fmt"
"log"
"net/http"
- "net/url"
"strconv"
"strings"
@@ -219,6 +218,11 @@ func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ s.handlePipelineSettingsPage(w, r, p, acct)
+}
+
+func (s *Server) handlePipelineSettingsPage(w http.ResponseWriter, r *http.Request, p page, acct *Account) {
+ setAppSettingsSection(&p, "pipeline")
ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout)
defer cancel()
pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, p.AppName)
@@ -239,6 +243,7 @@ func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request)
// row, load a template) and for a save that failed, so the human's
// in-progress edit is never silently thrown away.
func (s *Server) renderPipelineSettings(w http.ResponseWriter, p page, rows []PipelineFormRow, errMsg string) {
+ setAppSettingsSection(&p, "pipeline")
p.PipelineRows = rows
p.PipelineIsDefault = pipelineIsDefault(rows)
if errMsg != "" {
@@ -323,7 +328,7 @@ func (s *Server) handlePipelineSettingsSave(w http.ResponseWriter, r *http.Reque
if !s.requireFreshAuth(w, r, addr, raw) {
return
}
- dest := p.Base + "/apps/" + url.PathEscape(app) + "/pipeline"
+ dest := appSettingsURL(acct.Sqid, app, "pipeline")
if _, n := guardedEdit(cur, channels); n > 0 {
kind, msg := s.proposeThenExecute(ctx, acct, app, kindPolicy, policyProposal(cur, channels), n, addr)
s.setFlash(w, kind, msg)
diff --git a/internal/console/server.go b/internal/console/server.go
index cd68301a9..355a0e46f 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1132,6 +1132,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings/{section}", s.handleAppSettings)
// The pipeline settings page (pipelinepage.go, release pipelines v2 §5):
// GET renders the current (or default) channel list as an editable
// form; POST goes through the same guarded path the API PUT does.
@@ -1360,6 +1361,11 @@ type page struct {
// templates (Edge → Production, Full ladder) are worth offering.
PipelineRows []PipelineFormRow
PipelineIsDefault bool
+ SettingsSection string // app Settings sub-page selected by the URL
+ SettingsRenameTo string // retained draft after a General form refusal
+ SettingsDomain string // retained Domains attach draft
+ SettingsChannel string // retained Domains attach draft
+ SettingsNav []SettingsSectionLink
// App page: how a canary channel gets a URL, rendered as commands the
// reader can actually run from where they are reading. Computed
@@ -1591,6 +1597,11 @@ type page struct {
ServiceSecret *ServiceSecretView
}
+type SettingsSectionLink struct {
+ Key, Label, Href string
+ Current bool
+}
+
type appLogsCopy struct {
Instance, AllInstances, Search, AllLevels, Follow, Pause, Widen, FollowError, Refreshed string
}
@@ -3127,7 +3138,47 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- p.Settings = s.assembleSettings(r.Context(), &p, acct)
+ section := r.PathValue("section")
+ if section == "" {
+ section = "general"
+ }
+ if !appSettingsSections[section] {
+ http.NotFound(w, r)
+ return
+ }
+ if section == "pipeline" {
+ s.handlePipelineSettingsPage(w, r, p, acct)
+ return
+ }
+ setAppSettingsSection(&p, section)
+ s.populateAppSettings(r, &p, acct)
+ s.render(w, http.StatusOK, "app_settings", p)
+}
+
+func setAppSettingsSection(p *page, section string) {
+ p.SettingsSection = section
+ for _, item := range []struct{ key, label string }{
+ {"general", "General"},
+ {"domains", "Domains"},
+ {"pipeline", "Release pipeline"},
+ {"storage", "Storage"},
+ {"email", "Email"},
+ {"features", "Feature flags"},
+ {"advanced", "Advanced"},
+ {"delete", "Delete"},
+ } {
+ href := p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/settings"
+ if item.key != "general" {
+ href += "/" + item.key
+ }
+ p.SettingsNav = append(p.SettingsNav, SettingsSectionLink{
+ Key: item.key, Label: item.label, Href: href, Current: item.key == section,
+ })
+ }
+}
+
+func (s *Server) populateAppSettings(r *http.Request, p *page, acct *Account) {
+ p.Settings = s.assembleSettings(r.Context(), p, acct)
// The Errors log level this app resolves to, and where it comes from —
// the one setting the tab itself cannot change. A failed read is said
// rather than defaulted (see ErrLevelUnread), and never fails a page
@@ -3141,7 +3192,20 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
// The analytics switch is the OTHER half of that same resolved object —
// one settings file, one read — under the name its own section uses.
p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread
- s.render(w, http.StatusOK, "app_settings", p)
+}
+
+func (s *Server) renderAppSettingsForm(w http.ResponseWriter, r *http.Request, status int, section, msg string, drafts map[string]string) {
+ p, acct, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ setAppSettingsSection(&p, section)
+ p.FlashErr = msg
+ p.SettingsRenameTo = drafts["rename"]
+ p.SettingsDomain = drafts["domain"]
+ p.SettingsChannel = drafts["channel"]
+ s.populateAppSettings(r, &p, acct)
+ s.render(w, status, "app_settings", p)
}
// activityCap bounds the activity tab's own listing.
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index b9912b540..15b06a5e2 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -272,7 +272,7 @@ func TestConsoleE2E(t *testing.T) {
}
// Settings tab: the app's domains.
- status, _, sbody := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains"))
if status != 200 {
t.Fatalf("/apps/kass/settings = %d", status)
}
@@ -1405,12 +1405,16 @@ func TestAppSettingsTab(t *testing.T) {
t.Fatalf("GET app settings = %d", status)
}
for _, want := range []string{
- `aria-current="page">Settings</a>`, "<h2>Identity</h2>", "Domains &amp; TLS",
+ `aria-current="page">Settings</a>`, "<h2>Identity</h2>",
} {
if !strings.Contains(body, want) {
t.Fatalf("app settings missing %q", want)
}
}
+ _, _, domains := get(t, c, fx.u("/apps/kass/settings/domains"))
+ if !strings.Contains(domains, "Domains &amp; TLS") {
+ t.Fatal("app Domains settings section is missing its heading")
+ }
assertNonMember404(t, fx, "/apps/kass/settings")
}
@@ -1424,7 +1428,7 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
t.Fatal("keyless console is missing the disabled note")
}
// The Domains section is a read — keyless (read-only) mode still renders it.
- if _, _, sbody := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
+ if _, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
t.Fatal("keyless console is missing the Domains section")
}
_, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
@@ -1670,7 +1674,7 @@ func TestAppPageRendersDomains(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ _, _, body := get(t, c, fx.u("/apps/kass/settings/domains"))
for _, want := range []string{
"Domains", "kass.bab.carlosapp.dev", "platform", "pointing",
"kass.carlosapp.dev", "custom", "—",
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 8db7f22bb..dd3a53097 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -34,7 +34,7 @@
<p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p>
<p class="tile-detail">{{if .CPUSum}}CPU · memory, from the copies responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
</a>
- <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings/domains">
<h2>Web addresses</h2>
<p class="tile-value">{{.DomainTotal}}</p>
<p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}DNS &amp; certs OK{{end}}</p>
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 9b03d89e9..f1cd232d1 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -3,6 +3,12 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
+<div class="settings-layout">
+<nav class="settings-nav" aria-label="Settings sections">
+ {{range .SettingsNav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}
+</nav>
+<div class="settings-content">
+{{if eq .SettingsSection "general"}}
<h2>Identity</h2>
<div class="tablewrap">
<table>
@@ -10,7 +16,7 @@
<tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
</table>
</div>
-<p class="small"><a href="{{.Base}}/apps/{{.Detail.App}}/pipeline">Release pipeline</a> — the channels a
+<p class="small"><a href="{{.Nav}}/apps/{{.Detail.App}}/settings/pipeline">Release pipeline</a> — the channels a
release moves through, and what each one takes to promote onto.</p>
{{if .IsOwner}}
{{if .Settings.ControlPlane}}
@@ -20,8 +26,8 @@ refused while the flag is set, and a service credential cannot promote it.</p>
{{else if .Settings.CanRename}}
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
- <label class="visually-hidden" for="rename-to">New name</label>
- <input type="text" id="rename-to" name="to" placeholder="new-name"
+ <label for="rename-to">New name</label>
+ <input type="text" id="rename-to" name="to" value="{{.SettingsRenameTo}}" placeholder="new-name"
required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
<button type="submit" class="btn-sm">Rename</button>
</form>
@@ -33,6 +39,8 @@ bucket keys and stays.</p>
{{end}}
{{end}}
+{{end}}
+{{if eq .SettingsSection "domains"}}
<h2>Domains &amp; TLS</h2>
{{if .Settings.Domains}}
<div class="tablewrap">
@@ -70,11 +78,11 @@ bucket keys and stays.</p>
at its platform domain; attaching adds addresses, it never removes one.</p>{{end}}
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/domains/attach" class="settings-row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
- <label class="visually-hidden" for="attach-host">Domain</label>
- <input type="text" id="attach-host" name="host" placeholder="www.example.com" required>
+ <label for="attach-host">Domain</label>
+ <input type="text" id="attach-host" name="host" value="{{.SettingsDomain}}" placeholder="www.example.com" required>
{{if .Settings.NeedsChannel}}
- <label class="visually-hidden" for="attach-channel">Channel</label>
- <input type="text" id="attach-channel" name="channel" placeholder="channel (e.g. stable)">
+ <label for="attach-channel">Channel</label>
+ <input type="text" id="attach-channel" name="channel" value="{{.SettingsChannel}}" placeholder="channel (e.g. stable)">
{{end}}
<button type="submit" class="btn-sm">Attach domain</button>
</form>
@@ -122,6 +130,8 @@ page asking for it and stay signed in for a week. It is set per instance.</p>
</div>
{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
+{{end}}
+{{if eq .SettingsSection "general"}}
{{if and .Settings .Settings.MismatchTarget}}
<h2>Account</h2>
<p>This app's hosts read releases from account
@@ -150,6 +160,10 @@ different account moves its bucket objects and re-stamps its routes —
<code>carlos accounts migrate</code>, an operator act; once run, this page
offers the final console-record step.</p>
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "storage"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve object storage</p>{{end}}
+{{if .Settings.StoreUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
{{if .Settings}}{{with .Settings.Store}}
<h2>Object storage</h2>
{{if or .Declared .Granted .Provisioned}}
@@ -206,6 +220,8 @@ credentials — an operator of this deployment has to tear it down.</p>
and delivers its credentials into this app's config.</p>
{{end}}
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "general"}}
<h2>Errors</h2>
{{if .ErrLevelUnread}}
<p class="small">This app&rsquo;s Errors log level could not be read just now, so it is not shown
@@ -255,6 +271,10 @@ JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes eve
joined across days. Whether that needs consent where your visitors are is your call, which is why it
is off until you turn it on.</p>
+{{end}}
+{{if eq .SettingsSection "features"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read features</p>{{end}}
+{{if .Settings.FeaturesUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
{{if .Settings}}{{with .Settings.Features}}
<h2>Feature flags</h2>
{{if .Flags}}
@@ -276,6 +296,10 @@ lands on its next poll — no restart.</p>
<code>carlos features set --app {{$.Detail.App}} key=value</code>
(<code>key=</code> clears); every change is audited under Activity.</p>
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "email"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve email</p>{{end}}
+{{if .Settings.MailUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
{{if .Settings}}{{with .Settings.Mail}}
<h2>Email</h2>
{{if or .Declared .Granted .Provisioned .Standalone}}
@@ -340,6 +364,8 @@ none on the record; it was handed over once, at creation.</p>
{{end}}
{{end}}
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "advanced"}}
{{if and .Settings .IsOwner}}
<h2 class="danger-head">Control plane</h2>
@@ -370,6 +396,8 @@ exactly as before.</p>
this same card.</p>
{{end}}
{{end}}
+{{end}}
+{{if eq .SettingsSection "delete"}}
{{if .IsOwner}}
<h2 class="danger-head">Delete this app</h2>
@@ -387,4 +415,7 @@ blocks deletion — turn the safety delay off on the Versions tab first.{{end}}<
</form>
<p class="small">Confirms with your passkey.</p>
{{end}}
+{{end}}
+</div>
+</div>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 2c8be0b8b..69a2b123c 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -129,6 +129,21 @@
/* Settings: an input-and-button row, and the deletion section's heading
in the ink that says be careful. */
.settings-row { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin: 0 0 0.75rem; }
+ .settings-layout {
+ display: grid; grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
+ gap: 2.5rem; align-items: start;
+ }
+ .settings-nav { display: flex; flex-direction: column; gap: 0.2rem; }
+ .settings-nav a {
+ display: block; padding: 0.45rem 0.65rem; border-radius: 0.35rem;
+ color: var(--muted); text-decoration: none;
+ }
+ .settings-nav a:hover { color: var(--ink); background: var(--accent-soft); }
+ .settings-nav a[aria-current="page"] {
+ color: var(--ink); background: var(--accent-soft); font-weight: 600;
+ }
+ .settings-content { min-width: 0; }
+ .settings-content > h2:first-child { margin-top: 0; }
.danger-head { color: var(--bad); }
p { max-width: 40rem; margin: 0 0 1rem; }
.lede { font-size: 1.15rem; color: var(--muted); }
@@ -336,6 +351,12 @@
/* Narrow screens: the same path, turned down the page. The connector turns
with it — an arrow still pointing the way the version travels. */
@media (max-width: 46rem) {
+ .settings-layout { display: block; }
+ .settings-nav {
+ flex-direction: row; overflow-x: auto; margin: 0 0 1.5rem;
+ padding-bottom: 0.25rem;
+ }
+ .settings-nav a { white-space: nowrap; }
.flow-line { display: block; }
.flow-stage + .flow-stage { margin-top: 1.6rem; }
.flow-stage + .flow-stage::before {
diff --git a/internal/console/templates/pipeline.html b/internal/console/templates/pipeline.html
index 1fd9f6a38..dea50e7d6 100644
--- a/internal/console/templates/pipeline.html
+++ b/internal/console/templates/pipeline.html
@@ -3,6 +3,11 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
+<div class="settings-layout">
+<nav class="settings-nav" aria-label="Settings sections">
+ {{range .SettingsNav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}
+</nav>
+<div class="settings-content">
<h2>Release pipeline</h2>
<p class="small">The ordered list of channels a release moves through — the first is where
<code>carlos deploy</code> lands. Each channel can ask for a passkey and a number of members
@@ -57,4 +62,6 @@ from scratch with the row below. Nothing is written until you press Save.</p>
bypasses the order but never the rules. An edit that touches a channel the SAVED pipeline
already guards with change approvals becomes a proposal here too, at the quorum that channel
already demands.</p>
+</div>
+</div>
{{template "bottom" .}}{{end}}