Merge branch 'console-ux-02-settings' into main
18 files changed,
+382
−80
internal/console/analyticsview.go+1 −1internal/console/apifeatures_test.go+2 −2internal/console/appdomains.go+8 −3internal/console/appdomains_test.go+4 −4internal/console/appsettings.go+51 −12internal/console/appsettings_gate_test.go+4 −4internal/console/appsettings_sections_test.go+120 −0internal/console/appsettings_test.go+28 −21internal/console/controlplane.go+3 −4internal/console/controlplane_test.go+12 −12internal/console/errorsview.go+1 −1internal/console/pipelinepage.go+7 −2internal/console/server.go+66 −2internal/console/server_test.go+8 −4internal/console/templates/app_overview.html+1 −1internal/console/templates/app_settings.html+38 −7internal/console/templates/layout.html+21 −0internal/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 & TLS") { |
| + t.Fatal("General still renders the Domains section") |
| + } |
| + _, _, domains := get(t, c, fx.u("/apps/titogo/settings/domains")) |
| + if !strings.Contains(domains, "Domains & 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 & 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 & 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 & 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 & 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 & 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’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}} |