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 } // THE LOGIN ANSWER (v0.320.0) — every installed app's settings tab says where its login is, in ONE panel with ONE // answer. The box can show a password only if it knows it, so the answers are, in this order: // // fields a `type: password` field (and its name): the value fetched on demand, or — when the box holds none — // the v0.319.0 sentence (the empty-value rule above) // initial the app wrote a login at its first start and the box reads it (InitialCreds); FileBrowser's own // (R-513) is this answer too, and in its `operator` state it is a sentence and the „new password" press // first_visit the household makes the admin on the first visit — the catalog's app_info.login_kind, default // email the app signs in by an e-mailed link // none the app has no sign-in // // An infra stack with no login of its own (traefik, cloudflared, samba) gets no panel: it is not an app anyone signs // into. Pinned by TestAppPage_EveryInstalledAppHasOneLoginAnswer. const ( loginAnswerFields = "fields" loginAnswerInitial = "initial" ) // loginAnswerFor picks the answer from the page data the tab already assembled ("" = no panel). func loginAnswerFor(data map[string]interface{}, meta *stacks.Metadata) string { if f, _ := data["LoginFields"].([]AppLoginField); len(f) > 0 { return loginAnswerFields } if ic, _ := data["InitialCreds"].(*stacks.ExtractedCreds); ic != nil { return loginAnswerInitial } if infra, _ := data["Infra"].(bool); infra || meta == nil { return "" } return meta.AppInfo.EffectiveLoginKind() }