Files
felhom-controller/controller/internal/web/templates/app_settings.html
T
admin 7f3e58ff0a
gates / gates (push) Failing after 49s
The app page as one column — the approved mockup
v0.315.0 worked and looked, in the operator's words, messy: boxes of different widths stacked down
the page, with the same card shape around things you act on and things you only read. The mockup
approved on 2026-10-15 replaces it, and each of its three rules has a reason:

  ONE COLUMN OF ONE WIDTH (820px). Differing widths read as a page assembled by accident.
  SECTIONS ARE HAIRLINES, NOT CARDS. A card says "this is a thing you act on"; most of an app page
    is a thing you READ, so a card there is a promise the content cannot keep.
  A BOX MEANS SOMETHING. Exactly two remain: blue when the app is waiting for the household,
    amber when something is risky. The exception-colour principle applied to shape as well.

What moved:
  - the hero loses its box: a 64px logo tile, the name at 26px, the tagline, then hairline chips
    (state, lifecycle, update) and meta text (the address, the last automatic update);
  - the tabs are links with a 2px blue underline under the open one — no pills, no fills;
  - sections are `padding: 28px 0` with a bottom hairline; rows are a 200px label column so every
    value on the page starts at the same x, and stack under 720px;
  - the after-start callout is now the FIRST thing on Beallitasok. A household opening a Plex that
    wants its claim code should meet the code box, not a Restart button;
  - "2. mentes beallitasa — <app>" and "<app> — Exportalas" are gone: they were the headings of
    pages that no longer exist, on a tab that already says which app this is;
  - the settings tab gains "Cim es kulcsok" with one address row, and the disabled subdomain input
    that said the same thing is skipped for an installed app (the install wizard still asks);
  - the "this app is already installed, the settings are read-only" BANNER becomes the hint under
    the fields it is about;
  - the install wizard gets the same back link, hero and column, so pressing Telepites does not
    land the household on a page that looks like a different product.

THE AUTO-UPDATE SUCCESS BANNER IS GONE. It was a full-width blue box above the tabs, level with
"the update failed" and "your files are missing". A success that asked nothing of the household is
a date, and it is now meta text in the hero. Every banner that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — is untouched, above the tabs,
exactly where R-571 puts it.

Tests: TestAppPage_OneColumnLayout (one column, one aria-current, no card-shaped section, no
success banner), TestAppPage_AutoUpdateSuccessIsMetaNotBanner, TestAppPage_CalloutsOnlyForAskAndWarn
(one blue callout on a waiting app and it comes first; one amber on a same-disk backup; none on the
good case). Four red-proofs recorded in REPORT.

app_row_dedup_gate convicted `.app-row`, correctly: that class already means "a row in the app
list". The app page's label/value row is `.app-field-row`. Restoring the list's own rule after an
over-broad rename was part of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:29:56 +02:00

733 lines
35 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="deploy-container">
{{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" style="margin-top:1rem" 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" style="margin-top:1rem" 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" style="margin-top:1rem" 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" style="margin-top:1rem" 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">
{{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="deploy-storage-info">
<h4>{{T "deploy.adattarolas"}}</h4>
<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="deploy-stale-data">
<h4>{{T "deploy.korabbi_adatok"}}</h4>
<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-optional-config">
<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-optional-config">
<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-optional-config">
<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-optional-config">
<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}}
<div class="app-section">
<h3>{{T "app_page.address_and_keys"}}</h3>
{{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}}
<form id="deploy-form" class="deploy-form">
{{template "app_deploy_fields" .}}
</form>
<p class="app-field-hint" data-readonly-hint="1">{{T "app_page.installed_readonly"}}</p>
</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>
function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn');
if (!warn) return;
var freePct = parseFloat(opt.getAttribute('data-free-percent') || '100');
warn.style.display = freePct < 20 ? 'block' : 'none';
}
// Check on page load
document.addEventListener('DOMContentLoaded', function() {
var sel = document.querySelector('select[onchange="checkStorageSpace(this)"]');
if (sel) checkStorageSpace(sel);
});
// R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on
// demand, and the server records the act. Nothing caches it between presses.
// R-709 (v0.280.0): an installed app's `type: password` value, fetched on demand like a secret.
function revealPasswordField(stackName, envVar, btn) {
var el = document.getElementById('field-' + envVar);
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
btn.dataset.shown = '';
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';
el.dataset.revealed = '1';
btn.dataset.shown = '1';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
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}}