Files
felhom-controller/controller/internal/web/templates/app_settings.html
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

745 lines
37 KiB
HTML

{{define "app_settings"}}
{{template "layout_start" .}}
<div class="app-page">
{{template "app_shell_start" .}}
{{/* „BEALLITASOK" — the tab an installed app opens on.
Order, and each step of it is a decision:
1. Mukodes — Start / Restart / Stop. They used to sit in the page header and read as
menu buttons; a state change is an action on the app, so it is a card.
2. what the app still wants FROM the household — the setup gate, an after-start value, the
sign-up window, the first login. Above the form, because an app that is waiting on the
household is not asking them to review settings.
3. the settings themselves — generated values, install-time values (read-only), the optional
ones, e-mail, integrations, geo.
„Adatok es mentes" holds everything about the FILES, including the backup card that used to
sit on this page. */}}
<div class="app-settings-tab">
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
{{/* 1. WHAT THE APP IS WAITING FOR — the generic after-start card (Part 4, D2 = A).
FIRST on the tab (v0.316.0): a household opening a Plex that wants its claim code should
meet the code box, not a Restart button. It is the page's one BLUE callout — a request,
never a fault, so never amber.
The catalog says a value is asked after the The catalog says a value is asked after the
app starts and supplies its label; the ACTION is Go code in after_start.go, keyed by slug
and env var. A field with no registered action renders nothing and POSTs 404 — the catalog
cannot make the box run anything. */}}
{{- range .AfterStartFields}}
<div class="app-section app-callout app-callout-ask" data-afterstart="{{.EnvVar}}">
<h3>{{T "app_page.after_start_title"}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="input-with-button">
<input type="text" id="after-start-{{.EnvVar}}" class="form-control"
placeholder="{{.Placeholder}}" autocomplete="off" spellcheck="false"
aria-label="{{.Label}}">
<button type="button" class="btn btn-sm btn-primary"
onclick="appAfterStart(this, '{{$.Meta.Slug}}', '{{.EnvVar}}')">{{T "app_page.after_start_btn"}}</button>
</div>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{/* 2. MUKODES. Stop asks first — it takes the app away from everyone in the house, and the
protected stacks refuse it server-side regardless (traefik, cloudflared, the controller
itself and always samba). */}}
<div class="app-section" id="app-run-card" data-run-card="1">
<h3>{{T "app_page.mukodes"}}</h3>
<p class="form-hint" data-run-state="{{.Stack.State}}">{{stateLabel .Stack.State}}</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem">
{{if isOperational .Stack.State}}
<button class="btn btn-sm btn-warning" data-run-action="restart" onclick="stackAction(event, '{{.Stack.Name}}', 'restart')">{{T "common.ujrainditas"}}</button>
<button class="btn btn-sm btn-danger" data-run-action="stop" onclick="var b=this; felhomConfirm(b, '{{T "app_page.stop_confirm"}}', function(){ stackAction(event, '{{$.Stack.Name}}', 'stop'); })">{{T "deploy.leallitas"}}</button>
{{else}}
<button class="btn btn-sm btn-success" data-run-action="start" onclick="stackAction(event, '{{.Stack.Name}}', 'start')">{{T "deploy.inditas"}}</button>
{{end}}
</div>
</div>
{{/* 3. THE REST OF WHAT THE APP WANTS FROM THE HOUSEHOLD. */}}
{{- if .InstallHoldClosed}}
<div class="app-section" id="install-hold-card">
<h3>{{T "app_info.install_hold_title"}}</h3>
<p>{{T "app_info.install_hold_closed"}}</p>
</div>
{{- end}}
{{- if .SetupGateClosed}}
<div class="app-section" id="setup-gate-card">
<h3>{{T "app_info.setup_gate_title"}}</h3>
<p>{{T "app_info.setup_gate_closed"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
{{- if .SetupGateHasProbe}}
<p class="form-hint">{{T "app_info.setup_gate_probe"}}</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
{{- else}}
<p class="form-hint">{{T "app_info.setup_gate_button_hint"}}</p>
<button type="button" class="btn btn-sm btn-primary" onclick="var b=this; felhomConfirm(b, '{{T "app_info.setup_gate_confirm"}}', function(){ appPress(b, '/apps/{{.Meta.Slug}}/setup-gate/open'); })">{{T "app_info.setup_gate_done_btn"}}</button>
{{- end}}
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if .CloseSignupOffered}}
<div class="app-section" id="close-signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
<p>{{T "app_info.close_signup_text"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
<button type="button" class="btn btn-sm btn-primary" onclick="appPress(this, '/apps/{{.Meta.Slug}}/close-signup')">{{T "app_info.close_signup_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if or .SignupClosed .SignupOpenUntil}}
<div class="app-section" id="signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
{{- if .SignupOpenUntil}}
<p>{{T "app_info.signup_open_until"}} {{.SignupOpenUntil}}</p>
{{- else}}
<p>{{T "app_info.signup_closed"}}</p>
{{- end}}
{{- if .AppInfo.AddPeople}}
<p class="form-hint" id="add-people">{{T "app_info.signup_add_how"}} {{.AppInfo.AddPeople}}</p>
{{- end}}
{{- if .SignupNativeFailed}}
<p class="form-hint" id="signup-native-failed">{{T "app_info.signup_native_failed"}}</p>
{{- end}}
{{- if .SignupNativeOpenFailed}}
<p class="form-hint" id="signup-native-open-failed">{{T "app_info.signup_native_open_failed"}}</p>
{{- end}}
{{- if .SignupClosed}}
{{- if .SignupNative}}
<p class="form-hint">{{T "app_info.signup_window_restart"}}</p>
{{- end}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/signup-window')">{{T "app_info.signup_window_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
{{- end}}
{{/* The logins, each already its own guarded card on the details page. */}}
<div class="app-sections">
{{/* v0.319.0 — „BELEPES AZ ALKALMAZASBA": the name and the password of the app's first login,
side by side. The values are fetched on demand (R-254, R-709); the page learns only whether
the box holds one (app_login.go), and a field it holds none for is a sentence, not an eye. */}}
{{if .LoginFields}}
<div class="app-section" id="app-login-panel" data-login-panel="1">
<h3>{{T "app_page.login_title"}}</h3>
<p>{{T "app_page.login_desc"}}</p>
<div class="app-login-pair">
{{- range .LoginFields}}
<div class="app-login-field" data-login-field="{{.EnvVar}}">
<label class="app-login-label" for="field-{{.EnvVar}}">{{.Label}}</label>
{{- if .Restored}}
<p class="form-hint" data-login-restored="{{.EnvVar}}">{{T "deploy.login_from_backup"}}</p>
{{- else if not .Stored}}
<p class="form-hint" data-login-not-stored="{{.EnvVar}}">{{if .IsName}}{{T "app_page.login_no_name"}}{{else}}{{T "app_page.login_no_password"}}{{end}}</p>
{{- else if .OnDemand}}
<div class="input-with-button">
<input type="password" id="field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly>
<button type="button" class="btn btn-sm btn-outline" data-login-reveal="{{.EnvVar}}" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this, 'field-{{.EnvVar}}')">{{T "deploy.megjelenites"}}</button>
</div>
{{- else}}
<input type="text" id="field-{{.EnvVar}}" class="form-control" value="{{.Value}}" readonly>
{{- end}}
</div>
{{- end}}
</div>
</div>
{{end}}
{{if .InitialCreds}}
<div class="app-section">
<h3>{{T "app_info.kezdeti_belepesi_adatok"}}</h3>
<table class="initcred-table">
{{if .InitialCreds.Username}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">{{T "app_info.felhasznalonev"}}</td>
<td><code class="initcred-user" style="user-select:all">{{.InitialCreds.Username}}</code></td>
</tr>
{{end}}
{{if .InitialCredsOperatorSet}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">{{T "common.jelszo"}}</td>
<td>{{T "app_info.az_uzemelteto_allitotta_be"}}</td>
</tr>
{{else}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap;vertical-align:middle">{{T "common.jelszo"}}</td>
<!-- R-254: the value is NOT in this page. It used to be rendered into a `hidden`
span, which stops a browser drawing it and nothing else — a fetch of this page
returned a real per-install password. Both buttons now ask the server. -->
<td style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<code id="initcred-pw">••••••••••••</code>
<button type="button" class="btn btn-sm btn-outline" id="initcred-reveal" onclick="icRevealPw(this)">{{T "app_info.megjelenites"}}</button>
<button type="button" class="btn btn-sm btn-outline" onclick="icCopyPw(this)">{{T "app_info.masolas"}}</button>
<span id="initcred-err" class="form-hint" style="display:none;color:var(--red)"></span>
</td>
</tr>
{{end}}
</table>
{{if .InitialCreds.Note}}<p class="app-info-creds-warn">{{.InitialCreds.Note}}</p>{{end}}
{{if not .InitialCredsOperatorSet}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal"}}</p>{{end}}
</div>
{{end}}
{{if and .AppInfo.DefaultCreds (not .DefaultLoginReplaced)}}
<div class="app-section">
<h3>{{T "app_info.alapertelmezett_belepes"}}</h3>
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
{{if .KnownLoginLine}}<p class="app-info-creds-warn" id="known-login-line">{{.KnownLoginLine}}</p>{{else}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>{{end}}
{{- if and .Stack.Deployed .KnownLoginLine}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/default-login/changed')">{{T "app_info.default_login_changed_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
{{end}}</div>
{{/* 3. THE SETTINGS. v0.316.0: the „this app is already installed, the settings below are
read-only" BANNER is gone — it was a box saying what a one-line hint says, on a tab the
household reached by pressing „Beallitasok" on an installed app. The hint lives with the
fields it is about, at the bottom of this tab. */}}
{{if .StorageInfo}}
<div class="app-section">
<h3>{{T "deploy.adattarolas"}}</h3>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">{{T "deploy.tarhely"}}</span>
<span class="settings-value">{{.StorageInfo.Label}} <span class="mono" style="color:var(--text-2)">({{.StorageInfo.Path}})</span></span>
</div>
{{if .StorageInfo.DataSizeHuman}}
<div class="settings-row">
<span class="settings-label">{{T "deploy.adatmeret"}}</span>
<span class="settings-value mono">{{numText .StorageInfo.DataSizeHuman}}</span>
</div>
{{end}}
{{if .StorageInfo.FreeHuman}}
<div class="settings-row">
<span class="settings-label">{{T "deploy.szabad_hely"}}</span>
<span class="settings-value mono">{{T "deploy.szabad"}}</span>
</div>
{{end}}
</div>
{{if .OtherStoragePaths}}
<span class="btn btn-sm btn-outline" style="margin-top:.75rem;opacity:.45;cursor:not-allowed" title="{{T "deploy.hamarosan"}}">
<svg class="ico ico-sm"><use href="#i-upload"/></svg> {{T "deploy.mozgatas_masik_tarolora"}}
</span>
{{end}}
</div>
{{end}}
{{if .StaleData}}
<div class="app-section deploy-stale-data">
<h3>{{T "deploy.korabbi_adatok"}}</h3>
<p class="form-hint" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_adatainak_masolata_megtala"}}
</p>
{{range .StaleData}}
<div class="stale-data-item">
<div class="settings-grid" style="margin-bottom:.75rem">
<div class="settings-row">
<span class="settings-label">{{T "deploy.tarhely"}}</span>
<span class="settings-value">{{.Label}} <span class="mono" style="color:var(--text-2)">({{.Path}})</span></span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "deploy.meret"}}</span>
<span class="settings-value mono">{{numText .SizeHuman}}</span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "deploy.mappak"}}</span>
<span class="settings-value mono" style="font-size:.85rem">{{range .Mounts}}{{.}}<br>{{end}}</span>
</div>
</div>
<button class="btn btn-sm btn-danger" onclick="deleteStaleData('{{$.Meta.Slug}}', '{{.Path}}', this)">
{{T "deploy.korabbi_adatok_torlese"}}
</button>
</div>
{{end}}
</div>
{{end}}
{{if .HasIntegrations}}
<div class="app-section">
<h3>{{T "deploy.integraciok"}}</h3>
<p class="config-group-desc">
{{T "deploy.mas_telepitett_alkalmazasokkal_valo_ossz"}}
</p>
{{range .Integrations}}
<div class="config-field" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 0;border-bottom:1px solid var(--line)">
<div style="flex:1">
<strong>{{.Label}}</strong>
<p class="config-field-help" style="margin:0">{{.Description}}</p>
{{if not .TargetDeployed}}
<span class="tag tag-off" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.nincs_telepitve"}}</span>
{{else if not .TargetRunning}}
<span class="tag tag-neutral" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.celalkalmazas_leallitva"}}</span>
{{else if eq .Status "error"}}
<span class="tag tag-crit" style="margin-top:.25rem"><span class="dot"></span>{{T "common.hiba"}}</span>
{{else if .Enabled}}
<span class="tag tag-run" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.aktiv"}}</span>
{{end}}
</div>
<label class="toggle">
<input type="checkbox"
{{if .Enabled}}checked{{end}}
{{if not .TargetDeployed}}disabled title="{{T "deploy.a_celalkalmazas_nincs_telepitve"}}"{{end}}
{{if and .TargetDeployed (not .TargetRunning)}}disabled title="{{T "deploy.a_celalkalmazas_nem_fut"}}"{{end}}
onchange="toggleIntegration('{{$.Stack.Name}}', '{{.Target}}', this.checked, this)">
<span class="toggle-label"></span>
</label>
</div>
{{end}}
</div>
<script>
async function toggleIntegration(provider, target, enable, checkbox) {
checkbox.disabled = true;
try {
var resp = await fetch('/api/integrations/' + provider + '/' + target, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({enabled: enable})
});
var data = await resp.json();
if (!data.ok) {
checkbox.checked = !enable;
showAlert(data.error || '{{T "common.hiba_tortent"}}');
} else {
setTimeout(function(){ location.reload(); }, 500);
return;
}
} catch(err) {
checkbox.checked = !enable;
showAlert('{{T "deploy.halozati_hiba"}}');
}
checkbox.disabled = false;
}
</script>
{{end}}
{{if .GeoGlobalEnabled}}
<div class="app-section">
<h3>{{T "deploy.foldrajzi_korlatozas"}}</h3>
<p class="config-group-desc">
{{T "deploy.az_alkalmazas_egyeni_orszagkorlatozas_ne"}}
</p>
<label class="toggle" style="margin-bottom:1rem">
<input type="checkbox" id="app-geo-override-toggle"
{{if .GeoAppOverride}}checked{{end}}
onchange="toggleAppGeoOverride(this.checked)">
<span class="toggle-label">{{T "deploy.egyeni_orszagkorlatozas"}}</span>
</label>
<div id="app-geo-override-details" {{if not .GeoAppOverride}}style="display:none"{{end}}>
<div class="form-group">
<label>{{T "deploy.engedelyezett_orszagok_ehhez_az_alkalmaz"}}</label>
<div class="geo-country-selector">
<input type="text" id="app-geo-search" class="form-control config-input"
placeholder="{{T "deploy.orszag_keresese"}}" autocomplete="off"
oninput="filterAppGeoCountries(this.value)"
onfocus="showAppGeoList()"
onblur="setTimeout(function(){hideAppGeoList()},200)">
<div class="geo-country-list" id="app-geo-country-list"></div>
</div>
<div class="geo-selected-tags" id="app-geo-tags"></div>
</div>
<div class="config-actions">
<button class="btn btn-primary" onclick="saveAppGeoOverride()">{{T "deploy.mentes"}}</button>
<span id="app-geo-status" class="config-save-status"></span>
</div>
</div>
</div>
<script>
(function(){
var allCountries = [];
var appGeoCountries = {{json .GeoAppOverrideCountries}};
var stackName = '{{.Stack.Name}}';
function loadCountries(cb) {
if (allCountries.length > 0) { cb(); return; }
fetch('/api/geo/countries', {headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){ if(d.ok) allCountries = d.data; cb(); })
.catch(function(){ cb(); });
}
window.toggleAppGeoOverride = function(enabled) {
document.getElementById('app-geo-override-details').style.display = enabled ? '' : 'none';
if (enabled) {
if (!appGeoCountries || appGeoCountries.length === 0) {
appGeoCountries = {{json .GeoGlobalCountries}};
}
loadCountries(renderAppGeoTags);
} else {
fetch('/api/stacks/' + stackName + '/geo/override', {method:'DELETE', headers:csrfHeaders()});
}
};
window.showAppGeoList = function() { loadCountries(function(){ filterAppGeoCountries(''); }); };
window.hideAppGeoList = function() { document.getElementById('app-geo-country-list').style.display='none'; };
window.filterAppGeoCountries = function(q) {
var list = document.getElementById('app-geo-country-list');
q = q.toLowerCase();
var html = ''; var count = 0;
for (var i = 0; i < allCountries.length && count < 15; i++) {
var c = allCountries[i];
if (appGeoCountries.indexOf(c.code) >= 0) continue;
if (q && c.name.toLowerCase().indexOf(q) < 0 && c.code.toLowerCase().indexOf(q) < 0) continue;
html += '<div class="geo-country-option" onmousedown="addAppGeoCountry(\'' + c.code + '\')">'
+ esc(c.name) + ' <small>(' + c.code + ')</small></div>';
count++;
}
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">{{T "deploy.nincs_talalat"}}</div>';
list.style.display = (count > 0 || q) ? '' : 'none';
};
window.addAppGeoCountry = function(code) {
if (appGeoCountries.indexOf(code) >= 0) return;
appGeoCountries.push(code);
renderAppGeoTags();
document.getElementById('app-geo-search').value = '';
hideAppGeoList();
};
window.removeAppGeoCountry = function(code) {
appGeoCountries = appGeoCountries.filter(function(c){return c !== code});
renderAppGeoTags();
};
function renderAppGeoTags() {
var el = document.getElementById('app-geo-tags');
var html = '';
for (var i = 0; i < appGeoCountries.length; i++) {
var code = appGeoCountries[i];
var name = code;
for (var j = 0; j < allCountries.length; j++) {
if (allCountries[j].code === code) { name = allCountries[j].name; break; }
}
html += '<span class="geo-tag">' + esc(name) + ' (' + code + ') '
+ '<span class="geo-tag-remove" onclick="removeAppGeoCountry(\'' + code + '\')">&times;</span></span>';
}
el.innerHTML = html;
}
window.saveAppGeoOverride = function() {
var status = document.getElementById('app-geo-status');
fetch('/api/stacks/' + stackName + '/geo/override', {
method: 'POST',
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
body: JSON.stringify({allowed_countries: appGeoCountries})
})
.then(function(r){return r.json()})
.then(function(d){
status.textContent = d.ok ? (d.message || '{{T "deploy.mentve"}}') : (d.error || '{{T "common.hiba"}}');
status.className = 'config-save-status ' + (d.ok ? 'config-save-ok' : 'config-save-err');
setTimeout(function(){ status.textContent=''; }, 5000);
})
.catch(function(){
status.textContent = '{{T "deploy.halozati_hiba"}}';
status.className = 'config-save-status config-save-err';
});
};
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (document.getElementById('app-geo-override-toggle') && document.getElementById('app-geo-override-toggle').checked) {
loadCountries(renderAppGeoTags);
}
})();
</script>
{{end}}
{{if .HasOptionalConfig}}
<div class="app-section">
<h3>{{T "deploy.opcionalis_beallitasok"}}</h3>
{{range .OptionalConfig}}
<div class="config-group">
<h4>{{.Group}}</h4>
{{if .Description}}<p class="config-group-desc">{{.Description}}</p>{{end}}
<div class="config-fields">
{{range .Fields}}
<div class="config-field">
<label for="opt-{{.EnvVar}}">{{.Label}}</label>
{{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
{{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">{{T "deploy.regisztracios_utmutato"}}</a></p>{{end}}
{{if eq .Type "secret_input"}}
{{/* R-255 (v0.308.0): a stored secret is NOT in the page; it is fetched on demand like the
auto-generated ones (R-254). A blank field that was never revealed is not sent, so
saving other fields keeps it. */}}
{{$set := index $.CurrentValues .EnvVar}}
<div class="input-with-button">
<input type="password" id="opt-{{.EnvVar}}" name="{{.EnvVar}}" class="config-input"
value="" placeholder="{{if $set}}••••••••••••{{else}}{{.Label}}{{end}}"
{{if $set}}data-secret-set="1" {{end}}autocomplete="off">
{{if $set}}<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this, 'opt-{{.EnvVar}}')">{{T "deploy.megjelenites"}}</button>{{end}}
</div>
{{else}}
<input type="text"
id="opt-{{.EnvVar}}"
name="{{.EnvVar}}"
class="config-input"
value="{{index $.CurrentValues .EnvVar}}"
placeholder="{{.Label}}"
autocomplete="off">
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
<div class="config-actions">
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
{{T "deploy.mentes"}}
</button>
<span id="config-save-status" class="config-save-status"></span>
</div>
</div>
<script>
async function saveOptionalConfig(stackName) {
var btn = document.getElementById('save-optional-config');
var status = document.getElementById('config-save-status');
var inputs = document.querySelectorAll('.config-input');
var values = {};
inputs.forEach(function(input) {
// R-255: a stored secret the household never revealed and left blank stays as it is.
if (input.dataset.secretSet === '1' && input.dataset.revealed !== '1' && input.value === '') return;
values[input.name] = input.value;
});
btn.disabled = true;
btn.textContent = '{{T "deploy.mentes_2"}}';
status.textContent = '';
status.className = 'config-save-status';
try {
var resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({values: values})
});
var data = await resp.json();
if (data.ok) {
status.textContent = (data.message || '{{T "deploy.mentve"}}');
status.className = 'config-save-status config-save-ok';
} else {
status.textContent = (data.error || '{{T "common.hiba"}}');
status.className = 'config-save-status config-save-err';
}
} catch(err) {
status.textContent = '{{T "deploy.halozati_hiba"}}';
status.className = 'config-save-status config-save-err';
}
btn.disabled = false;
btn.textContent = '{{T "deploy.mentes"}}';
setTimeout(function() { status.textContent = ''; }, 5000);
}
</script>
{{end}}
{{if .AppEmailSupported}}
<div class="app-section">
<h3>{{T "deploy.email_kuldes"}}</h3>
<p class="config-group-desc">
{{T "deploy.ez_az_alkalmazas_tud_emailt"}}
</p>
{{if not .AppEmailGlobalOn}}
<p class="alert alert-warning" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_email_jelenleg_ki"}}
</p>
{{end}}
<form method="POST" action="/stacks/{{.Stack.Name}}/app-email">
{{$.CSRFField}}
<label style="display:flex;align-items:center;gap:.5rem;margin-bottom:1rem">
<input type="checkbox" name="app_email_enabled" value="on" {{if .AppEmailAppOn}}checked{{end}}>
{{T "deploy.email_kuldes_engedelyezese_ennel_az"}}
</label>
<div class="config-actions">
<button class="btn btn-primary" type="submit">{{T "deploy.mentes"}}</button>
<span class="config-group-desc" style="margin-left:.5rem">{{T "deploy.a_mentes_ujrainditja_az_alkalmazast"}}</span>
</div>
</form>
</div>
{{end}}
{{/* „CIM ES KULCSOK" (v0.319.0) — what was set at install, as rows. The hint that says they
cannot be changed sits under the title it is about, not at the bottom of the tab. The domain,
the subdomain and the data folder are the „Cim" and „Tarhely" rows; the login is the panel
above; everything else is a row here (app_login.go appSettingRows). */}}
<div class="app-section" data-address-keys="1">
<h3>{{T "app_page.address_and_keys"}}</h3>
<p class="app-field-hint" data-readonly-hint="1">{{T "app_page.installed_readonly"}}</p>
{{if .EffectiveSubdomain}}
<div class="app-field-row">
<div class="app-field-label">{{T "app_page.address"}}</div>
<div class="app-field-value" data-app-address-row="1">{{.EffectiveSubdomain}}.{{.Domain}}</div>
</div>
{{end}}
{{if .CurrentHDDPath}}
<div class="app-field-row">
<div class="app-field-label">{{T "deploy.tarhely"}}</div>
<div class="app-field-value" data-app-storage-row="1">{{if .CurrentHDDPathMissing}}{{T "deploy.nem_elerheto"}}{{else if .CurrentHDDLabel}}{{.CurrentHDDLabel}}{{else}}<span class="mono">{{.CurrentHDDPath}}</span>{{end}}</div>
</div>
{{end}}
{{- range .SettingsRows}}
<div class="app-field-row" data-setting-row="{{.EnvVar}}">
<div class="app-field-label">{{.Label}}</div>
{{- if .OnDemand}}
<div class="app-field-value input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly aria-label="{{.Label}}">
<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this)">{{T "deploy.megjelenites"}}</button>
</div>
{{- else}}
<div class="app-field-value">{{.Value}}</div>
{{- end}}
</div>
{{- end}}
</div>
</div>
{{if .InitialCreds}}
<script>
// R-254: the password is NOT in this page. It is fetched on demand from the server, which re-reads it
// live from the running container — so this is the only way it reaches a browser, and every fetch is
// recorded server-side. Nothing caches it in a variable between presses: each act asks again.
function icFetchPw() {
return fetch('/apps/{{.Meta.Slug}}/initial-credentials/reveal', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { throw new Error(j.error || '{{T "app_info.a_kezdeti_jelszo_beolvasasa_nem"}}'); }
return j.data.password;
});
}
function icErr(msg) {
var e = document.getElementById('initcred-err');
e.textContent = msg;
e.style.display = 'inline';
}
function icRevealPw(btn) {
var code = document.getElementById('initcred-pw');
if (!code) return;
document.getElementById('initcred-err').style.display = 'none';
if (code.dataset.shown === '1') { // hide: drop the value out of the DOM again
code.textContent = '••••••••••••';
code.dataset.shown = '0';
btn.textContent = '{{T "app_info.megjelenites"}}';
return;
}
btn.disabled = true;
icFetchPw().then(function (pw) {
btn.disabled = false;
code.textContent = pw;
code.dataset.shown = '1';
btn.textContent = '{{T "app_info.elrejtes"}}';
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
function icCopyPw(btn) {
document.getElementById('initcred-err').style.display = 'none';
btn.disabled = true;
icFetchPw().then(function (pw) {
return navigator.clipboard.writeText(pw).then(function () {
btn.disabled = false;
var orig = btn.textContent;
btn.textContent = '{{T "app_info.masolva"}}';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
</script>
{{end}}
{{template "app_press_script" .}}
<script>
// R-254 site two: on an already-deployed app no secret is in this page — it is fetched on demand,
// and the server records the act. Nothing caches it between presses. Since v0.319.0 the login
// panel's name and password use this too (R-709: a `type: password` value, fetched like a secret).
function revealAutoField(stackName, envVar, btn, elId) {
var el = document.getElementById(elId || ('auto-field-' + envVar));
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
el.dataset.revealed = '';
btn.dataset.shown = '';
btn.textContent = '{{T "deploy.megjelenites"}}';
return;
}
btn.disabled = true;
fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
credentials: 'same-origin',
body: 'env_var=' + encodeURIComponent(envVar)
}).then(function (r) { return r.json(); }).then(function (j) {
btn.disabled = false;
if (!j.ok) { showAlert(j.error || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
btn.dataset.shown = '1';
btn.textContent = '{{T "deploy.elrejtes"}}';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
function deleteStaleData(stackName, stalePath, btn) {
// Two-step inline confirm (the irreversible delete keeps its double acknowledgement).
felhomConfirm(btn, '{{T "deploy.biztosan_torolni_szeretned_a_korabbi"}} ' + stalePath + ' {{T "deploy.figyelem_a_muvelet_visszavonhatatlan_elo"}}', function () {
felhomConfirm(btn, '{{T "deploy.utolso_figyelmeztetes_a_torles_visszavon"}}', function () {
deleteStaleDataRun(stackName, stalePath, btn);
});
});
}
function deleteStaleDataRun(stackName, stalePath, btn) {
btn.disabled = true;
btn.textContent = '{{T "deploy.torles_folyamatban"}}';
fetch('/api/storage/stale-cleanup', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) {
showAlert('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "deploy.ismeretlen_hiba"}}'));
btn.disabled = false;
btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}';
return;
}
var msg = '{{T "deploy.korabbi_adatok_torolve_n_nfelszabaditott"}} ' + (data.freed_human || '?');
if (data.errors && data.errors.length > 0) {
msg += '{{T "deploy.n_nnehany_hiba_tortent_n"}}' + data.errors.join('\n');
}
showAlert(msg);
// Remove the stale data card from DOM
var item = btn.closest('.stale-data-item');
if (item) item.remove();
// If no more stale items, remove the whole section
var container = document.querySelector('.deploy-stale-data');
if (container && container.querySelectorAll('.stale-data-item').length === 0) {
container.remove();
}
})
.catch(function(e) {
showAlert('{{T "deploy.halozati_hiba_2"}} ' + e.message);
btn.disabled = false;
btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}';
});
}
</script>
</div>{{/* .app-page */}}
{{template "layout_end" .}}
{{end}}