Files
felhom-controller/controller/internal/web/app_login.go
T
admin d170a2aecf
gates / gates (push) Successful in 52s
v0.319.0: the app page in panels, the login in one place, a password the box never had is said in words
- style A (operator 2026-10-16): hero, tab bar and every section are panels on --bg-1, one width, 16px apart;
  the install page's form groups use the same rule; the settings tab's 700px inner wrapper is gone
- „Belépés az alkalmazásba": name + password side by side, fetched on demand (R-254, R-709); pairing in app_login.go
- the empty-value rule: no stored value -> a sentence, never an eye; page and reveal endpoint share storedValuePresent
- „Cím és kulcsok" is rows (no Domain input, no disabled storage select, no install form); hint under the title
- tests red-proved: app_page_tuning_test.go (4); 36 app-page parity fixtures re-captured, 4 new; install fixtures unchanged

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:12:06 +02:00

160 lines
5.9 KiB
Go

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
}