diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c37cae..5106810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## v0.319.0 — the app page in panels, the login in one place, a password the box never had is said in words (2026-10-16) + +**MinAgent: 0.157.0** — unchanged; nothing here calls the agent. No golden (`09` §3 decision 250). Design: felhom.eu +`architecture/02-controller-module-map.md` „The app page" (`[DESIGN]`, operator-approved 2026-10-16, section style A). + +- **Panels (style A).** The hero, the tab bar and every section of the four tabs are panels on `--bg-1`, the column's + full width, 16px apart; the open tab is filled `--bg-2` with a 2px `--blue-bright` underline; a panel's title sits 18px + from its top edge with a soft rule under it. The install page's form groups and its progress panel use the same rule. + The settings tab's inner 700px wrapper is gone (it made the settings panels narrower than the column). +- **„Belépés az alkalmazásba"** — one panel with the app's login name and password side by side (stacked under 720px), + both fetched on demand as before (R-254, R-709). Pairing: a `type: password` field plus the `secret` or `text` field with + the same prefix and `USER`/`USERNAME`/`EMAIL` (`web/app_login.go`). Pairs today: calibre-web, kimai, lubelogger, + nextcloud, paperless-ngx, radicale; the other 14 password apps show the password alone. +- **The empty-value rule.** A login field the box holds no value for is a sentence („A doboz ennél a telepítésnél nem + mentett jelszót…"), never an eye that answers 404. The page and the reveal endpoint decide „stored" with one function + (`storedValuePresent`). Measured cause on demo-hp: Calibre-Web was installed 2026-08-21, its template gained + `ADMIN_PASSWORD` on 2026-09-29, and `InjectMissingFields` never fills a `type: password` field + (felhom.eu `audits/app-page-tuning-2026-10-16/P0`). Its stored user name is the real one (renamed in the app 2026-10-01). +- **„Cím és kulcsok"** — the settings tab no longer re-renders the install form: no Domain input, no disabled storage + select, no „Automatikusan generált értékek" block. The read-only hint sits under the title; the storage is a row + „Tárhely: "; the other install answers and generated keys are rows (keys keep their eye). +- Guide: the screenshots are a panel that hides itself when no screenshot loads; the missing-guide line is a panel. Data: + the export's progress and done boxes are panels. Logs: the log box is one panel. +- `/apps/{slug}/install` opened for an INSTALLED app still renders the install form read-only with its secret guards; + two parity cases now render it (`deploy_installed`, `deploy_installed_restored`). +- Tests red-proved: `app_page_tuning_test.go` (TestAppPage_LoginPairPanel, _NoStoredValueShowsSentence, + _SettingsHasNoDuplicateDomain, _OneSectionStyleEverywhere); the storage-row tests re-targeted from the old select. + Parity fixtures: 36 app-page fixtures re-captured (the per-task exception, markup only); 4 new + (`app_settings_login_not_stored`, `app_settings_login_text_name`, `deploy_installed`, `deploy_installed_restored`); + every install-page fixture and every other fixture byte-identical. + ## v0.318.0 — the internal data place, partitions, an honest whole-box backup offer, sys_drive never offered (2026-10-16) **MinAgent: 0.157.0** — nothing here calls a new agent endpoint. The internal place and the partitions appear only when the diff --git a/REUSE.md b/REUSE.md index 5f9e55f..e972ec7 100644 --- a/REUSE.md +++ b/REUSE.md @@ -204,6 +204,7 @@ | `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials | | `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative | | `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps//initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks//auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. | +| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. | ### Storage registry + mount detection diff --git a/controller/README.md b/controller/README.md index ee768e3..d8d36e4 100644 --- a/controller/README.md +++ b/controller/README.md @@ -344,7 +344,7 @@ card for moving data), and every „Vissza" went to the app LIST, so there was n | URL | Tab | What is on it | |---|---|---| | `/apps/{slug}` | — | the default: the settings tab when installed, the guide when not. **Rendered, not redirected** — the app's address stays its address | -| `/apps/{slug}/settings` | „Beállítások" | „Működés" (Start / Restart / Stop, Stop confirms), the cards that want something from the household (setup gate, after-start fields, sign-up window, first login), then the generated values, the install-time values read-only, the optional settings, e-mail, integrations, geo | +| `/apps/{slug}/settings` | „Beállítások" | „Működés" (Start / Restart / Stop, Stop confirms), the cards that want something from the household (setup gate, after-start fields, sign-up window, first login), „Belépés az alkalmazásba" (the login name and password side by side, v0.319.0), the optional settings, e-mail, integrations, geo, then „Cím és kulcsok" (address, storage, the other install answers and generated keys as rows) | | `/apps/{slug}/data` | „Adatok és mentés" | „Hova tegyem a fájlokat?", move to another drive, the nightly copy, the 2nd-copy setting, export | | `/apps/{slug}/guide` | „Útmutató" | tagline, screenshots, what it is for, first steps, the catalog's guide, the FAQ, the docs link. For a NOT-installed app this is the whole page, with „Telepítés" | | `/apps/{slug}/logs` | „Napló" | the log, and the only tab whose poller runs | @@ -362,8 +362,14 @@ app-email toggle and the reveal endpoints keep their URLs and their handlers. Code: `internal/web/app_page.go` (routing, the tabs, the redirects), `templates/app_shell.html` (the one header, the banners, the tab bar), `app_settings.html` / `app_data.html` / `app_guide.html` / `app_logs.html`, and -`app_fields.html` — the configuration fields, shared byte-for-byte by the install wizard and the settings tab so the -two cannot drift. +`app_fields.html` — the install wizard's configuration fields (since v0.319.0 the settings tab shows rows and the login +panel instead, `internal/web/app_login.go`). + +**Layout (v0.319.0, operator-approved 2026-10-16, section style A):** the hero, the tab bar and every section are panels +on `--bg-1`, the column's full width, 16px apart; the open tab is filled `--bg-2` with a 2px `--blue-bright` underline; +the install page's form groups use the same panel rule. **A login field the box holds no value for is a sentence, never an +eye** — the page and the reveal endpoint share `storedValuePresent`. Design: felhom.eu `architecture/02-controller-module-map.md` +„The app page's layout". #### „Még egy lépés" — a value asked AFTER the app starts (v0.315.0) diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 16a4683..1117c0b 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -118,6 +118,11 @@ "app_page.address_and_keys": "Address and keys", "app_page.address": "Address", "app_page.installed_readonly": "These were created at install time and cannot be changed.", + "app_page.login_title": "Signing in to the app", + "app_page.login_desc": "For the first sign-in. If you change the password in the app, the old one stays here.", + "app_page.login_no_password": "The box saved no password for this install. Sign in with the one you set in the app.", + "app_page.login_no_name": "The box saved no user name for this install. Sign in with the one you set in the app.", + "app_page.value_no": "No", "app_page.after_start_btn": "Save", "app_page.after_start_title": "One more step", "app_page.faq": "Frequently asked questions", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index b92fbde..7f8fb5d 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -114,6 +114,11 @@ "app_page.address_and_keys": "Cím és kulcsok", "app_page.address": "Cím", "app_page.installed_readonly": "Ezek telepítéskor jöttek létre, és nem módosíthatók.", + "app_page.login_title": "Belépés az alkalmazásba", + "app_page.login_desc": "Az első bejelentkezéshez. Ha az alkalmazásban megváltoztatod a jelszót, itt a régi marad.", + "app_page.login_no_password": "A doboz ennél a telepítésnél nem mentett jelszót. A belépéshez azt használd, amit az alkalmazásban beállítottál.", + "app_page.login_no_name": "A doboz ennél a telepítésnél nem mentett felhasználónevet. A belépéshez azt használd, amit az alkalmazásban beállítottál.", + "app_page.value_no": "Nem", "app_page.after_start_btn": "Mentés", "app_page.after_start_title": "Még egy lépés", "app_page.faq": "Gyakori kérdések", diff --git a/controller/internal/web/app_login.go b/controller/internal/web/app_login.go new file mode 100644 index 0000000..c92ff0d --- /dev/null +++ b/controller/internal/web/app_login.go @@ -0,0 +1,159 @@ +package web + +import ( + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// THE LOGIN PANEL AND THE „CIM ES KULCSOK" ROWS (v0.319.0) — the settings tab's view of what was +// set at install. +// +// Until v0.318.0 the settings tab re-rendered the install wizard's form with every input disabled: +// the generated login name sat in „Automatikusan generalt ertekek" next to a disabled Domain input, +// and the password sat three sections lower among the other install answers. The household had to +// find two halves of one login. Now one panel holds the pair, and the rest are label/value rows. +// +// THE EMPTY-VALUE RULE. A field the box holds no value for is SAID, never offered an eye: the reveal +// endpoint answers 404 for it („Ehhez a mezohoz nincs mentett ertek"), which is a refusal the +// household could only meet by pressing. Measured 2026-10-16 on demo-hp: Calibre-Web was installed +// on 2026-08-21, its template gained ADMIN_PASSWORD on 2026-09-29, and InjectMissingFields never +// fills a `type: password` field — so the box never had one (audits/app-page-tuning-2026-10-16/P0). +// The page and the endpoint decide „stored" with ONE function, storedValuePresent, so the page +// cannot offer what the endpoint would refuse. Pinned by TestAppPage_NoStoredValueShowsSentence. +// +// SECRETS. A `type: secret` name and every `type: password` value stay off the page (R-254, R-709); +// only the boolean Stored reaches the template. A `type: text` name is not a secret — the old +// disabled input carried it as its value — and is shown as text. + +// storedValuePresent is the one definition of „the box holds a value for this field", shared by the +// settings tab and appAutoFieldRevealHandler's 404 branch. +func storedValuePresent(val string) bool { return strings.TrimSpace(val) != "" } + +// AppLoginField is one half of the login panel. Value is set ONLY for a plain-text name; a secret +// or a password never carries its value here. +type AppLoginField struct { + EnvVar string + Label string + IsName bool // the name half; false for the password half + OnDemand bool // fetched on demand through /stacks/{name}/auto-field/reveal + Value string // a `type: text` name only + Stored bool // the box holds a non-blank value + Restored bool // R-694: a restore generated it, so it is not the app's login and is never shown +} + +// AppSettingRow is one label/value row of „Cim es kulcsok". An OnDemand row carries no value; the +// eye fetches it. +type AppSettingRow struct { + EnvVar string + Label string + Value string + OnDemand bool +} + +// loginNameSuffixes are the env-var endings the catalog uses for the name next to a password: +// ADMIN_USER + ADMIN_PASSWORD (calibre-web), ADMIN_EMAIL + ADMIN_PASSWORD (kimai), +// NEXTCLOUD_ADMIN_USER + NEXTCLOUD_ADMIN_PASSWORD, and so on — measured over the catalog on +// 2026-10-16, every pair shares the prefix. +var loginNameSuffixes = []string{"USER", "USERNAME", "EMAIL"} + +// loginFieldIndexes returns, in deploy_fields order, the indexes of the login fields: every +// `type: password` field (not asked after start), each preceded by its name field when the +// template has one with the same prefix and type `secret` or `text`. A password with no clean +// pair stands alone. +func loginFieldIndexes(meta *stacks.Metadata) []int { + if meta == nil { + return nil + } + byEnv := make(map[string]int, len(meta.DeployFields)) + for i, f := range meta.DeployFields { + byEnv[f.EnvVar] = i + } + var out []int + for i, f := range meta.DeployFields { + if f.Type != "password" || f.AfterStart { + continue + } + // ADMIN_PASSWORD -> prefix "ADMIN_" -> ADMIN_USER / ADMIN_USERNAME / ADMIN_EMAIL. + prefix := strings.TrimSuffix(f.EnvVar, "PASSWORD") + if prefix != f.EnvVar { + for _, suf := range loginNameSuffixes { + if j, ok := byEnv[prefix+suf]; ok { + if t := meta.DeployFields[j].Type; (t == "secret" || t == "text") && !meta.DeployFields[j].AfterStart { + out = append(out, j) + break + } + } + } + } + out = append(out, i) + } + return out +} + +// appLoginFields builds the panel from the DECRYPTED env (before any value is removed from it). +func appLoginFields(meta *stacks.Metadata, env map[string]string, restored map[string]bool) []AppLoginField { + var out []AppLoginField + for _, i := range loginFieldIndexes(meta) { + f := meta.DeployFields[i] + lf := AppLoginField{EnvVar: f.EnvVar, Label: f.Label, IsName: true, Stored: storedValuePresent(env[f.EnvVar])} + switch f.Type { + case "password": + lf.IsName, lf.OnDemand, lf.Restored = false, true, restored[f.EnvVar] + case "secret": + lf.OnDemand = true + default: + lf.Value = env[f.EnvVar] + } + out = append(out, lf) + } + return out +} + +// appSettingRows is everything else an installed app was given at install, as rows: the generated +// secrets (eye) and the plain answers (text). Skipped, because the page says them elsewhere: the +// domain and the subdomain (the „Cim" row), the data folder (the „Tarhely" row), the login fields +// (the login panel), and a field asked after start (its own card). +func appSettingRows(meta *stacks.Metadata, env map[string]string, yes, no string) []AppSettingRow { + if meta == nil { + return nil + } + login := map[int]bool{} + for _, i := range loginFieldIndexes(meta) { + login[i] = true + } + var out []AppSettingRow + for i, f := range meta.DeployFields { + if login[i] || f.AfterStart { + continue + } + val := env[f.EnvVar] + switch f.Type { + case "domain", "subdomain", "path", "password": + continue + case "secret": + if storedValuePresent(val) { + out = append(out, AppSettingRow{EnvVar: f.EnvVar, Label: f.Label, OnDemand: true}) + } + continue + case "select": + for _, o := range f.Options { + if o.Value == val { + val = o.Label + break + } + } + case "boolean": + if val == "true" { + val = yes + } else { + val = no + } + } + if !storedValuePresent(val) { + continue + } + out = append(out, AppSettingRow{EnvVar: f.EnvVar, Label: f.Label, Value: val}) + } + return out +} diff --git a/controller/internal/web/app_page_tuning_test.go b/controller/internal/web/app_page_tuning_test.go new file mode 100644 index 0000000..dcf8257 --- /dev/null +++ b/controller/internal/web/app_page_tuning_test.go @@ -0,0 +1,434 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// APP PAGE TUNING, ROUND 2 (v0.319.0) — the login panel, the empty-value rule, the settings tab +// without the install form's duplicates, and one section style on every surface. +// +// The page tests go through s.ServeHTTP (the production route switch and handler), because the +// thing under test is what the HANDLER tells the template: a template rendered with hand-built +// LoginFields would assert nothing about whether the box's own values stay out of the page. +// Assertions are on ASCII `data-*` markers and ids, never on accented Hungarian. + +// The two values the box holds for an installed Calibre-Web. Neither may appear in any page. +const ( + tuningUser = "TESTONLY-user-q7x" + tuningPassword = "TESTONLY-pw-Lm93!" + tuningKey = "TESTONLY-key-Zr55" +) + +// calibreMeta is the catalog's calibre-web deploy_fields (templates/calibre-web/.felhom.yml, read +// 2026-10-16), plus one generated key so a non-login secret row is exercised too. +const calibreMeta = "display_name: Calibre-Web\nslug: calibre-web\nsubdomain: books\n" + + "deploy_fields:\n" + + " - env_var: DOMAIN\n label: Domain\n type: domain\n locked_after_deploy: true\n" + + " - env_var: SUBDOMAIN\n label: Aldomain\n type: subdomain\n default: books\n required: true\n" + + " - env_var: HDD_PATH\n label: Konyvtar utvonal\n type: path\n required: true\n" + + " - env_var: ADMIN_USER\n label: Admin nev\n type: secret\n generate: \"hex:5\"\n" + + " - env_var: ADMIN_PASSWORD\n label: Admin jelszo\n type: password\n generate: \"password:24:special\"\n" + + " - env_var: SECRET_KEY\n label: Titkos kulcs\n type: secret\n generate: \"hex:16\"\n" + +// tuningHarness: `calibre-web` installed with the given env, and `calibre-new` — the same template, +// not installed — for the install page. +func tuningHarness(t *testing.T, env map[string]string) *Server { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + stub := "#!/bin/sh\nif [ \"$1\" = ps ]; then\n printf 'calibre-web\\tbusybox\\trunning\\tUp 2 hours\\tcalibre-web\\n'\nfi\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + + lg := log.New(io.Discard, "", 0) + cfg := config.Default() + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + for _, app := range []string{"calibre-web", "calibre-new"} { + d := filepath.Join(cfg.Paths.StacksDir, app) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + meta := strings.Replace(calibreMeta, "slug: calibre-web", "slug: "+app, 1) + for name, body := range map[string]string{ + "docker-compose.yml": "services:\n " + app + ":\n image: busybox\n", + ".felhom.yml": meta, + } { + if err := os.WriteFile(filepath.Join(d, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + } + if err := stacks.SaveAppConfig(filepath.Join(cfg.Paths.StacksDir, "calibre-web"), + &stacks.AppConfig{Deployed: true, Env: env}, nil, nil); err != nil { + t.Fatal(err) + } + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/hdd_1", Label: "Kulso lemez", IsDefault: true, Schedulable: true}); err != nil { + t.Fatal(err) + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + _ = mgr.ScanStacks() + if _, ok := mgr.GetStack("calibre-web"); !ok { + t.Fatal("calibre-web was not discovered by ScanStacks") + } + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} + s.loadTemplates() + return s +} + +func tuningEnv() map[string]string { + return map[string]string{ + "DOMAIN": "example.hu", "SUBDOMAIN": "books", "HDD_PATH": "/mnt/felhom-drives/hdd_1", + "ADMIN_USER": tuningUser, "ADMIN_PASSWORD": tuningPassword, "SECRET_KEY": tuningKey, + } +} + +// revealStatus asks the reveal endpoint exactly as the eye button does, and returns its status. +func revealStatus(s *Server, envVar string) int { + r := httptest.NewRequest(http.MethodPost, "/stacks/calibre-web/auto-field/reveal", strings.NewReader("env_var="+envVar)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.appAutoFieldRevealHandler(w, r, "calibre-web") + return w.Code +} + +// sectionOf returns the element that starts at the first `marker` up to its matching . +func sectionOf(t *testing.T, body, marker string) string { + t.Helper() + i := strings.Index(body, marker) + if i < 0 { + t.Fatalf("no %s in the page", marker) + } + start := strings.LastIndex(body[:i], "]*>`) + +var revealCallRe = regexp.MustCompile(`revealAutoField\('calibre-web','([A-Z_]+)'`) + +// TestAppPage_LoginPairPanel (Scenario B) — ONE „Belepes az alkalmazasba" panel holds the name and +// the password, name first; both are fetched on demand, so neither value is in the page; and the +// eye on each is one the endpoint answers. +// +// RED-PROOF (REPORT.md): render the panel without the name (drop the pairing loop in +// loginFieldIndexes) -> the ADMIN_USER assertion fails. Reverted. +func TestAppPage_LoginPairPanel(t *testing.T) { + s := tuningHarness(t, tuningEnv()) + rec := appGet(t, s, "/apps/calibre-web/settings") + if rec.Code != http.StatusOK { + t.Fatalf("GET settings = %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, v := range []string{tuningUser, tuningPassword, tuningKey} { + if strings.Contains(body, v) { + t.Fatalf("a stored secret value is in the settings page (R-254/R-709)") + } + } + if n := strings.Count(body, `data-login-panel="1"`); n != 1 { + t.Fatalf("%d login panels, want exactly 1", n) + } + panel := sectionOf(t, body, `data-login-panel="1"`) + u, p := strings.Index(panel, `data-login-field="ADMIN_USER"`), strings.Index(panel, `data-login-field="ADMIN_PASSWORD"`) + if u < 0 || p < 0 { + t.Fatalf("the login panel must hold BOTH halves (user=%d password=%d)", u, p) + } + if u > p { + t.Error("the name must come before the password") + } + if !strings.Contains(panel, `class="app-login-pair"`) { + t.Error("the pair is not laid out side by side (app-login-pair)") + } + for _, f := range []string{"ADMIN_USER", "ADMIN_PASSWORD"} { + if !strings.Contains(panel, `data-login-reveal="`+f+`"`) { + t.Errorf("no eye for %s in the login panel", f) + } + if n := strings.Count(body, `id="field-`+f+`"`); n != 1 { + t.Errorf("field %s shown %d times, want once (Scenario E)", f, n) + } + } + // The login fields are shown ONCE: not also as a „Cim es kulcsok" row. + if strings.Contains(body, `data-setting-row="ADMIN_USER"`) || strings.Contains(body, `data-setting-row="ADMIN_PASSWORD"`) { + t.Error("a login field is shown twice — in the panel and as a row") + } + // Every eye on the page is one the endpoint answers with a value. + for _, m := range revealCallRe.FindAllStringSubmatch(body, -1) { + if code := revealStatus(s, m[1]); code != http.StatusOK { + t.Errorf("the page offers an eye for %s and the endpoint answers %d", m[1], code) + } + } +} + +// TestAppPage_NoStoredValueShowsSentence (Scenario C) — the box holds no password (Calibre-Web +// installed before its template had one, measured on demo-hp 2026-10-16): the page says so in a +// sentence and offers no eye, because the endpoint would answer 404 for it. +// +// RED-PROOF (REPORT.md): render the eye regardless (Stored: true in appLoginFields) -> the +// no-eye and the every-eye-answers assertions fail. Reverted. +func TestAppPage_NoStoredValueShowsSentence(t *testing.T) { + env := tuningEnv() + delete(env, "ADMIN_PASSWORD") + env["ADMIN_USER"] = " " // a blank value is no value: the endpoint's own rule + s := tuningHarness(t, env) + body := appGet(t, s, "/apps/calibre-web/settings").Body.String() + + for _, f := range []string{"ADMIN_PASSWORD", "ADMIN_USER"} { + if code := revealStatus(s, f); code != http.StatusNotFound { + t.Fatalf("precondition: the endpoint must answer 404 for %s, got %d", f, code) + } + if !strings.Contains(body, `data-login-not-stored="`+f+`"`) { + t.Errorf("%s: no sentence for a field the box holds no value for", f) + } + if strings.Contains(body, `data-login-reveal="`+f+`"`) || strings.Contains(body, `revealAutoField('calibre-web','`+f+`'`) { + t.Errorf("%s: an eye is offered for a value the endpoint answers 404 for", f) + } + } + for _, m := range revealCallRe.FindAllStringSubmatch(body, -1) { + if code := revealStatus(s, m[1]); code != http.StatusOK { + t.Errorf("the page offers an eye for %s and the endpoint answers %d", m[1], code) + } + } +} + +// TestAppPage_SettingsHasNoDuplicateDomain (Scenario E) — the settings tab carries no Domain input +// (the address is the „Cim" row), no disabled storage select (it is the „Tarhely" row), no install +// form, and the read-only hint sits under the „Cim es kulcsok" title. The install page keeps its +// fields (Scenario D's byte-identity is the parity fixtures' job; this asserts the fields are there). +// +// RED-PROOF (REPORT.md): put `{{template "app_deploy_fields" .}}` back into the settings tab -> the +// Domain/deploy-form assertions fail. Reverted. +func TestAppPage_SettingsHasNoDuplicateDomain(t *testing.T) { + s := tuningHarness(t, tuningEnv()) + body := appGet(t, s, "/apps/calibre-web/settings").Body.String() + for _, gone := range []string{`id="auto-field-DOMAIN"`, `id="field-DOMAIN"`, `id="deploy-form"`, `id="field-HDD_PATH"`, `id="field-SUBDOMAIN"`} { + if strings.Contains(body, gone) { + t.Errorf("the settings tab still renders %s", gone) + } + } + if n := strings.Count(body, `data-app-address-row="1"`); n != 1 { + t.Errorf("%d address rows, want 1", n) + } + keys := sectionOf(t, body, `data-address-keys="1"`) + hint, addr := strings.Index(keys, `data-readonly-hint="1"`), strings.Index(keys, `data-app-address-row="1"`) + if hint < 0 || addr < 0 || hint > addr { + t.Errorf("the read-only hint must sit under the title, above the rows (hint=%d address=%d)", hint, addr) + } + if strings.Count(body, `data-readonly-hint="1"`) != 1 { + t.Error("the read-only hint must appear once, inside the address-and-keys section") + } + if row := sectionOf(t, keys, `data-app-storage-row="1"`); !strings.Contains(row, "Kulso lemez") { + t.Errorf("the storage row must name the app's drive, got %q", row) + } + if !strings.Contains(keys, `data-setting-row="SECRET_KEY"`) { + t.Error("a generated non-login key must still be a row with its eye") + } + + inst := appGet(t, s, "/apps/calibre-new/install") + if inst.Code != http.StatusOK { + t.Fatalf("GET install = %d", inst.Code) + } + ib := inst.Body.String() + for _, want := range []string{`id="deploy-form"`, `id="field-SUBDOMAIN"`, `id="field-ADMIN_PASSWORD"`, `id="auto-field-ADMIN_USER"`, `generatePassword('field-ADMIN_PASSWORD'`} { + if !strings.Contains(ib, want) { + t.Errorf("the install page lost %s", want) + } + } +} + +// ── one section style ───────────────────────────────────────────────────────────────────────────── + +var ( + tagRe = regexp.MustCompile(`<(/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*?)(/?)>`) + classAttrRe = regexp.MustCompile(`class="([^"]*)"`) + stripBlockRe = regexp.MustCompile(`(?s)||`) + voidTags = map[string]bool{"input": true, "img": true, "br": true, "hr": true, "meta": true, "link": true, "source": true, "use": true, "path": true} + // Wrappers that carry no look of their own: their children are the page's sections. + transparentWrappers = []string{"app-sections", "app-guide", "app-data-tab", "app-settings-tab"} + // Not sections: the save/failure flash lines at the top of a tab. + messageClasses = []string{"flash", "monitoring-banner"} +) + +func hasClass(attrs, want string) bool { + m := classAttrRe.FindStringSubmatch(attrs) + if m == nil { + return false + } + for _, c := range strings.Fields(m[1]) { + if c == want { + return true + } + } + return false +} + +// tabTopLevelBlocks returns, for a rendered app-page TAB, every block the household sees between the +// tab bar and the end of the column — looking through the transparent wrappers — as its opening tag. +func tabTopLevelBlocks(t *testing.T, where, body string) []string { + t.Helper() + body = stripBlockRe.ReplaceAllString(body, "") + nav := strings.Index(body, `class="app-tabs"`) + if nav < 0 { + t.Fatalf("%s: no tab bar", where) + } + end := strings.Index(body[nav:], "") + rest := body[nav+end+len(""):] + var stack []bool // true = transparent wrapper + var out []string + for _, m := range tagRe.FindAllStringSubmatch(rest, -1) { + closing, name, attrs, self := m[1] == "/", strings.ToLower(m[2]), m[3], m[4] == "/" + if closing { + if len(stack) == 0 { + return out // the column itself closed + } + stack = stack[:len(stack)-1] + continue + } + if voidTags[name] || self { + continue + } + level := 0 + for _, tr := range stack { + if !tr { + level++ + } + } + transparent := false + if name == "div" { + for _, w := range transparentWrappers { + if hasClass(attrs, w) { + transparent = true + } + } + } + if level == 0 && !transparent { + out = append(out, m[0]) + } + stack = append(stack, transparent) + } + return out +} + +// TestAppPage_OneSectionStyleEverywhere — style A, the operator's choice of 2026-10-16: every section +// on the four tabs is an `.app-section` panel, the install page's form groups and progress panel are +// the same panel by one CSS rule, and no surface keeps the old flat hairline rule or a box of its own. +// +// Rendered through the parity cases (every branch the fixtures carry, both the rich and the bare data) +// and the production route for the bare harness app. +// +// RED-PROOF (REPORT.md): leave the logs tab on its old markup (`
`, no +// app-section) -> the app_logs case fails. Reverted. +func TestAppPage_OneSectionStyleEverywhere(t *testing.T) { + s := i18nTestServer(t) + tabs := map[string]bool{"app_settings": true, "app_data": true, "app_guide": true, "app_logs": true} + checked := map[string]int{} + for _, c := range i18nCases() { + if !tabs[c.tmpl] { + continue + } + body := renderI18nCase(t, s, "hu", c) + if !strings.Contains(body, `class="app-tabs"`) { + continue // a not-installed app's guide has no tab bar; the route test below covers it + } + checked[c.tmpl]++ + for _, tag := range tabTopLevelBlocks(t, c.name, body) { + if hasClass(tag, "app-section") { + continue + } + msg := false + for _, mc := range messageClasses { + msg = msg || hasClass(tag, mc) + } + if !msg { + t.Errorf("%s: a block outside the section style: %s", c.name, tag) + } + } + for _, legacy := range []string{`class="app-optional-config"`, `class="deploy-storage-info"`, `class="card"`, `class="deploy-container"`, `id="deploy-form"`} { + if strings.Contains(body, legacy) { + t.Errorf("%s: a tab still carries the old box %s", c.name, legacy) + } + } + } + for tmpl := range tabs { + if checked[tmpl] == 0 { + t.Fatalf("no parity case exercised %s — the check proved nothing for that tab", tmpl) + } + } + + // A not-installed app: its one view is the guide, without a tab bar — sections after the hero. + hs := appPageHarness(t) + g := appGet(t, hs, "/apps/grocy").Body.String() + g = stripBlockRe.ReplaceAllString(g, "") + if i := strings.Index(g, `data-guide-missing="1"`); i < 0 || !strings.Contains(g[strings.LastIndex(g[:i], "")] + if strings.Count(form, `

`) != strings.Count(form, `
`) { + t.Errorf("%s: a form group on the install page is not a form-section", c.name) + } + } + css, err := os.ReadFile(filepath.Join("templates", "style.css")) + if err != nil { + t.Fatal(err) + } + panelRule := regexp.MustCompile(`(?s)\.app-section,\s*\.app-page \.deploy-form \.form-section,\s*\.app-page \.deploy-progress\s*\{([^}]*)\}`).FindSubmatch(css) + if panelRule == nil { + t.Fatal("style.css: the one panel rule (.app-section + the install page's form-section and progress) is gone") + } + for _, want := range []string{"background: var(--bg-1)", "border: 1px solid var(--line)", "padding: 18px 24px 22px", "margin: 0 0 16px"} { + if !strings.Contains(string(panelRule[1]), want) { + t.Errorf("style.css: the panel rule lacks %q (style A)", want) + } + } + // No .app-section rule may redraw it the old way: v0.316.0's flat `padding: 28px 0` + hairline. + for _, m := range regexp.MustCompile(`([^{}]*\.app-section[^{}]*)\{([^}]*)\}`).FindAllSubmatch(css, -1) { + if strings.Contains(string(m[2]), "padding: 28px 0") { + t.Errorf("style.css: %s still carries the old flat section rule", strings.TrimSpace(string(m[1]))) + } + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index e5f53cb..5c9efb0 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -489,9 +489,15 @@ func (s *Server) deployPageData(w http.ResponseWriter, r *http.Request, name str if appCfg != nil { for _, n := range appCfg.RestoredLogins { restored[n] = true - delete(decryptedEnv, n) } } + // v0.319.0: the settings tab's login panel and „Cim es kulcsok" rows, read BEFORE any value + // is dropped below — they learn only whether a value is stored, never the secret itself. + data["LoginFields"] = appLoginFields(meta, decryptedEnv, restored) + data["SettingsRows"] = appSettingRows(meta, decryptedEnv, s.msgLang(lang, "deploy.igen"), s.msgLang(lang, "app_page.value_no")) + for n := range restored { + delete(decryptedEnv, n) + } // R-709 (v0.280.0): a `type: password` value is never written into this page's HTML either — like a // `type: secret` (R-254) it is fetched on demand (/stacks//auto-field/reveal). for _, f := range meta.DeployFields { @@ -578,6 +584,7 @@ func (s *Server) deployPageData(w http.ResponseWriter, r *http.Request, name str for _, dp := range deployPaths { if dp.Path == hdd { inList = true + data["CurrentHDDLabel"] = dp.Label // v0.319.0: the settings tab's „Tarhely" row break } } @@ -2626,7 +2633,7 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques return } val := crypto.DecryptMap(s.encKey, appCfg.Env)[envVar] - if strings.TrimSpace(val) == "" { + if !storedValuePresent(val) { // the settings tab reads the same function, so it never offers this 404 w.Header().Set("Cache-Control", "no-store") escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.no_stored_value")) return diff --git a/controller/internal/web/i18n_cases_a_test.go b/controller/internal/web/i18n_cases_a_test.go index acef2a2..27e0e1c 100644 --- a/controller/internal/web/i18n_cases_a_test.go +++ b/controller/internal/web/i18n_cases_a_test.go @@ -187,6 +187,18 @@ func i18nCasesA() []i18nCase { {"deploy_new_memory_blocked", "deploy", func() map[string]interface{} { return i18nDeployData(false, stacks.StateNotDeployed, 3) }}, + // v0.319.0: `/apps/{slug}/install` opened for an app that IS installed — the form read-only, its + // secrets fetched on demand. The settings tab stopped rendering this form, so these two cases are + // what renders its installed-app branches now. + {"deploy_installed", "deploy", func() map[string]interface{} { + return i18nDeployData(true, stacks.StateRunning, 1) + }}, + {"deploy_installed_restored", "deploy", func() map[string]interface{} { + d := i18nDeployData(true, stacks.StateRunning, 1) + d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"} + d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true} + return d + }}, {"app_settings_running", "app_settings", func() map[string]interface{} { return i18nDeployData(true, stacks.StateRunning, 1) }}, @@ -198,6 +210,26 @@ func i18nCasesA() []i18nCase { d := i18nDeployData(true, stacks.StateRunning, 1) d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"} d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true} + d["LoginFields"] = []AppLoginField{{EnvVar: "ADMIN_PASSWORD", Label: "Admin jelszó mező", OnDemand: true, Stored: true, Restored: true}} + return d + }}, + // v0.319.0: the box holds no login value (Calibre-Web installed before its template had one) — + // a sentence for each half, never an eye that answers 404. + {"app_settings_login_not_stored", "app_settings", func() map[string]interface{} { + d := i18nDeployData(true, stacks.StateRunning, 1) + d["LoginFields"] = []AppLoginField{ + {EnvVar: "ADMIN_USER", Label: "Admin név mező", IsName: true, OnDemand: true}, + {EnvVar: "ADMIN_PASSWORD", Label: "Admin jelszó mező", OnDemand: true}, + } + return d + }}, + // v0.319.0: a plain-text login name (Nextcloud, Paperless) is shown as text beside the password. + {"app_settings_login_text_name", "app_settings", func() map[string]interface{} { + d := i18nDeployData(true, stacks.StateRunning, 2) + d["LoginFields"] = []AppLoginField{ + {EnvVar: "PAPERLESS_ADMIN_USER", Label: "Admin név mező", IsName: true, Value: "konyveles", Stored: true}, + {EnvVar: "PAPERLESS_ADMIN_PASSWORD", Label: "Admin jelszó mező", OnDemand: true, Stored: true}, + } return d }}, {"settings_system_full", "settings_system", func() map[string]interface{} { @@ -399,5 +431,18 @@ func i18nDeployData(deployed bool, st stacks.ContainerState, variant int) map[st d["CurrentHDDPathMissing"] = true d["StoragePaths"] = nil } + // v0.319.0: the settings tab's login panel and „Cim es kulcsok" rows (app_login.go). The install + // page reads neither key, so its fixtures do not move. + if deployed { + d["CurrentHDDLabel"] = "HDD egy" + d["LoginFields"] = []AppLoginField{ + {EnvVar: "ADMIN_USER", Label: "Admin név mező", IsName: true, OnDemand: true, Stored: true}, + {EnvVar: "ADMIN_PASSWORD", Label: "Admin jelszó mező", OnDemand: true, Stored: true}, + } + d["SettingsRows"] = []AppSettingRow{ + {EnvVar: "SECRET_KEY", Label: "Titkos kulcs érték", OnDemand: true}, + {EnvVar: "LANG_SEL", Label: "Felület nyelve", Value: "Magyar nyelv"}, + } + } return d } diff --git a/controller/internal/web/launcher_polish_test.go b/controller/internal/web/launcher_polish_test.go index 2840e3e..c5c0308 100644 --- a/controller/internal/web/launcher_polish_test.go +++ b/controller/internal/web/launcher_polish_test.go @@ -115,9 +115,11 @@ func TestAppInfo_HeroPlaceholder_ScreenshotsUntouched(t *testing.T) { if !strings.Contains(html, canonical3Step) { t.Error("app_info hero logo must use the canonical 3-step chain") } - // Screenshots must be byte-identical to before — they still vanish on error, no placeholder. - if !strings.Contains(html, `class="app-screenshot"`) || !strings.Contains(html, `onerror="this.style.display='none'"`) { - t.Error("app_info screenshot imgs must keep their original vanish-on-error onerror") + // Screenshots still vanish on error, no placeholder. v0.319.0: through appShotGone, which also + // hides the screenshots panel when the last one is gone — a panel around nothing is not drawn. + if !strings.Contains(html, `class="app-screenshot"`) || !strings.Contains(html, `onerror="appShotGone(this)"`) || + !strings.Contains(html, "img.style.display = 'none';") { + t.Error("app_info screenshot imgs must vanish on error") } if strings.Contains(html, `class="app-screenshot" `) && strings.Contains(html, `app-screenshot" data-fallback`) { t.Error("app_info screenshots must NOT gain a placeholder fallback") diff --git a/controller/internal/web/networkstub_test.go b/controller/internal/web/networkstub_test.go index 38628a7..8748d4e 100644 --- a/controller/internal/web/networkstub_test.go +++ b/controller/internal/web/networkstub_test.go @@ -173,22 +173,11 @@ deploy_fields: return s } -// optionTag returns the full block whose value attribute equals path. -func optionTag(t *testing.T, body, path string) string { - t.Helper() - marker := `value="` + path + `"` - i := strings.Index(body, marker) - if i < 0 { - t.Fatalf("no option with value %q in rendered page", path) - } - start := strings.LastIndex(body[:i], "") - return body[start : i+end] -} - -// Deployed on the NAS → the NAS option carries `selected`; the default drive does NOT. -// Companion red-proof: revert deploy.html to the IsDefault-only selection → the default option is -// selected instead → both assertions FAIL (the exact S-C lie from the RCA). +// Deployed on the NAS → the „Tarhely" row names the NAS, never the default drive. +// v0.319.0: the settings tab shows the storage as a row, not a disabled select — the RCA fix 4 rule is +// unchanged: the view states the STORED HDD_PATH. +// Companion red-proof: make the row read the default drive's label → the first assertion FAILS (the exact +// S-C lie from the RCA). func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) { s := testDeployPageServer(t, "/mnt/felhom-drives/nas-media") // v0.315.0: an installed app's fields are the app page's settings TAB. The old /stacks/…/deploy @@ -197,28 +186,34 @@ func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) { if rec.Code != 200 { t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String()) } - body := rec.Body.String() - if nas := optionTag(t, body, "/mnt/felhom-drives/nas-media"); !strings.Contains(nas, "selected") { - t.Errorf("the STORED path's option must be selected, got: %s", nas) + row := storageRow(t, rec.Body.String()) + if !strings.Contains(row, "nas-media") { + t.Errorf("the storage row must name the STORED path's drive, got: %s", row) } - if usb := optionTag(t, body, "/mnt/felhom-drives/felhom-usb"); strings.Contains(usb, "selected") { - t.Errorf("the default drive must NOT be selected for a deployed app, got: %s", usb) + if strings.Contains(row, "felhom-usb") { + t.Errorf("the storage row must NOT name the default drive for a deployed app, got: %s", row) } } -// Stored path absent from the schedulable list → an extra disabled option names it verbatim (the -// view must never silently show a different storage than app.yaml). +// storageRow returns the settings tab's „Tarhely" value cell. +func storageRow(t *testing.T, body string) string { + t.Helper() + i := strings.Index(body, `data-app-storage-row="1"`) + if i < 0 { + t.Fatalf("no storage row in the rendered settings tab") + } + return body[i : i+strings.Index(body[i:], "
")] +} + +// Stored path absent from the schedulable list → the row names it verbatim as not available (the view +// must never silently show a different storage than app.yaml). func TestDeployPage_MissingStoredPathRendersTruthOption(t *testing.T) { s := testDeployPageServer(t, "/mnt/felhom-drives/gone") rec := getPage(t, s, "/apps/testapp/settings") if rec.Code != 200 { t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String()) } - body := rec.Body.String() - if !strings.Contains(body, "/mnt/felhom-drives/gone (nem elérhető)") { - t.Errorf("missing stored path must render as a disabled truth option") - } - if gone := optionTag(t, body, "/mnt/felhom-drives/gone"); !strings.Contains(gone, "selected") || !strings.Contains(gone, "disabled") { - t.Errorf("the truth option must be selected+disabled, got: %s", gone) + if row := storageRow(t, rec.Body.String()); !strings.Contains(row, "/mnt/felhom-drives/gone (nem elérhető)") { + t.Errorf("a missing stored path must render as its own path, not available; got: %s", row) } } diff --git a/controller/internal/web/templates/app_data.html b/controller/internal/web/templates/app_data.html index dd68dce..4f59f73 100644 --- a/controller/internal/web/templates/app_data.html +++ b/controller/internal/web/templates/app_data.html @@ -18,7 +18,7 @@ {{if .FlashError}}
{{.FlashError}}
{{end}} {{if .DataPathCards}} -
+

{{T "app_info.hova_tegyem_a_fajlokat"}}

{{T "app_info.ezeket_a_mappakat_a_fajlkezeloben"}}

@@ -36,7 +36,7 @@ {{end}} {{if and .Stack.Deployed .MigrateTargets}} -
+

{{T "app_info.athelyezes_masik_tarhelyre"}}

{{T "app_info.ennek_az_alkalmazasnak_az_adatait"}}

@@ -87,7 +87,7 @@ function appMigrate(btn,app,label){ {{/* The nightly copy, stated as a fact. It was a card on the settings page; the files it is about live here. */}} -
+

{{T "deploy.biztonsagi_mentes"}}

@@ -255,13 +255,13 @@ function appMigrate(btn,app,label){
-