Files
admin 977665d8c0
gates / gates (push) Successful in 27s
The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:42:47 +02:00

539 lines
26 KiB
HTML

{{define "settings_security"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>{{T "settings_security.beallitasok_biztonsag_es_hozzaferes"}}</h2>
</div>
<!-- Section B: Password Change -->
<div class="settings-card">
<h3>{{T "settings_security.jelszo_modositas"}}</h3>
{{if .AuthEnabled}}
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
<form method="POST" action="/settings/password">
{{.CSRFField}}
<div class="form-group">
<label for="current_password">{{T "settings_security.jelenlegi_jelszo"}}</label>
<input type="password" id="current_password" name="current_password" required
placeholder="{{T "settings_security.adja_meg_a_jelenlegi_jelszavat"}}" class="form-control">
</div>
<div class="form-group">
<label for="new_password">{{T "settings_security.uj_jelszo"}}</label>
<input type="password" id="new_password" name="new_password" required minlength="8"
placeholder="{{T "settings_security.legalabb_8_karakter"}}" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">{{T "settings_security.uj_jelszo_megerositese"}}</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="8"
placeholder="{{T "settings_security.jelszo_megegyszer"}}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">{{T "settings_security.jelszo_modositasa"}}</button>
</form>
{{else}}
<div class="alert alert-info">
{{T "settings_security.a_jelszavas_vedelem_nincs_beallitva"}}
</div>
{{end}}
</div>
<!-- Section: Geo-Restriction -->
<div class="settings-card">
<h3>{{T "settings_security.foldrajzi_korlatozas"}}</h3>
<p class="settings-card-desc">
{{T "settings_security.orszag_alapjan_korlatozhato_a_webes"}}
<br><span class="form-hint">{{T "settings_security.a_helyi_halozati_hozzaferes_mindig"}}</span>
</p>
{{if not .CFConfigured}}
<div class="alert alert-info">
{{T "settings_security.a_cloudflare_api_token_nincs"}}
</div>
{{else}}
<div id="geo-status-msg"></div>
<label class="toggle" style="margin-bottom:1rem">
<input type="checkbox" id="geo-enabled" {{if .GeoEnabled}}checked{{end}}
onchange="toggleGeo(this.checked)">
<span class="toggle-label">{{T "settings_security.geo_korlatozas_aktiv"}}</span>
</label>
<div id="geo-details" {{if not .GeoEnabled}}style="display:none"{{end}}>
<!-- Global allowed countries -->
<div class="form-group">
<label>{{T "settings_security.engedelyezett_orszagok_globalis"}}</label>
<div class="geo-country-selector" id="geo-countries">
<input type="text" id="geo-search" class="form-control"
placeholder="{{T "settings_security.orszag_keresese"}}"
autocomplete="off"
oninput="filterCountries(this.value)"
onfocus="showCountryList()"
onblur="setTimeout(function(){hideCountryList()},200)">
<div class="geo-country-list" id="geo-country-list"></div>
</div>
<div class="geo-selected-tags" id="geo-selected-tags"></div>
<span class="form-hint">{{T "settings_security.csak_a_kivalasztott_orszagokbol_erheto"}}</span>
</div>
<!-- Per-app overrides -->
<div class="form-group" style="margin-top:1.5rem">
<label>{{T "settings_security.alkalmazas_specifikus_felulirasok"}}</label>
<div id="geo-app-overrides"></div>
{{if .DeployedApps}}
<div style="margin-top:.5rem;display:flex;align-items:center;gap:.5rem">
<select id="geo-add-app-select" class="form-control" style="max-width:250px">
<option value="">{{T "settings_security.alkalmazas_kivalasztasa"}}</option>
{{range .DeployedApps}}
<option value="{{.Name}}">{{.Display}}</option>
{{end}}
</select>
<button class="btn btn-sm btn-outline" onclick="addAppOverride()">{{T "settings_security.hozzaadas"}}</button>
</div>
{{end}}
</div>
<!-- Sync status & save -->
<div class="form-group" style="margin-top:1.5rem">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<button class="btn btn-primary" id="btn-geo-save" onclick="saveGeoSettings()">
{{T "settings_security.mentes_es_szinkronizalas"}}
</button>
<button class="btn btn-sm btn-outline" onclick="triggerGeoSync()">{{T "settings_security.kezi_szinkronizalas"}}</button>
<span id="geo-sync-status" class="form-hint">
{{if .GeoLastSync}}{{T "settings_security.utolso_szinkronizalas"}}{{end}}
{{if .GeoLastError}} <span class="state-text-crit">{{.GeoLastError}}</span>{{end}}
</span>
</div>
</div>
</div>
{{end}}
</div>
<div id="dialog-root"></div>
<script>
// Light overlay dialog (D1) — replaces the native blocking browser dialogs (same texts).
function escText(s){var d=document.createElement('div');d.textContent=String(s==null?'':s);return d.innerHTML;}
function closeDialog(){ var r=document.getElementById('dialog-root'); if(r) r.innerHTML=''; }
function openDialog(opts){
var root=document.getElementById('dialog-root');
if(!root) return;
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)document.getElementById(\'dialog-cancel\').click()"><div class="confirm-box">'
+'<h3>'+escText(opts.title||'{{T "settings_security.megerosites"}}')+'</h3>'
+'<p style="white-space:pre-line">'+escText(opts.message||'')+'</p>'
+'<div class="form-actions"><button id="dialog-go" class="btn btn-primary">'+escText(opts.confirmLabel||'{{T "settings_security.megerosites"}}')+'</button>'
+'<button type="button" class="btn btn-outline" id="dialog-cancel">{{T "common.megsem"}}</button></div>'
+'</div></div>';
document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); };
document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); };
}
(function(){
// Geo-restriction UI state
var allCountries = [];
var selectedCountries = {{json .GeoAllowedCountries}};
var appOverrides = {{json .GeoAppOverrides}};
// Load countries list on first use
function ensureCountries(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.toggleGeo = function(enabled) {
document.getElementById('geo-details').style.display = enabled ? '' : 'none';
if (enabled) ensureCountries(renderTags);
};
window.showCountryList = function() {
ensureCountries(function(){ filterCountries(document.getElementById('geo-search').value); });
};
window.hideCountryList = function() {
document.getElementById('geo-country-list').style.display = 'none';
};
window.filterCountries = function(query) {
var list = document.getElementById('geo-country-list');
var q = query.toLowerCase();
var html = '';
var count = 0;
for (var i = 0; i < allCountries.length && count < 15; i++) {
var c = allCountries[i];
if (selectedCountries.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="addCountry(\'' + c.code + '\',\'' + escHtml(c.name) + '\')">'
+ escHtml(c.name) + ' <small>(' + c.code + ')</small></div>';
count++;
}
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">{{T "settings_security.nincs_talalat"}}</div>';
// Reveal with 'block', NOT '' — the .geo-country-list CSS default is display:none,
// and clearing the inline style ('') would fall back to that and keep the (populated)
// list hidden. This was the country-autocomplete "no list" bug.
list.style.display = count > 0 || q ? 'block' : 'none';
};
window.addCountry = function(code, name) {
if (selectedCountries.indexOf(code) >= 0) return;
selectedCountries.push(code);
renderTags();
document.getElementById('geo-search').value = '';
hideCountryList();
};
window.removeCountry = function(code) {
if (code === 'HU') {
openDialog({title:'{{T "settings_security.figyelem"}}', confirmLabel:'{{T "common.eltavolitas"}}',
message:'{{T "settings_security.figyelem_magyarorszag_eltavolitasa_azt_j"}}',
onConfirm:function(){ doRemoveCountry(code); }});
return;
}
doRemoveCountry(code);
};
function doRemoveCountry(code) {
selectedCountries = selectedCountries.filter(function(c){return c !== code});
renderTags();
}
function renderTags() {
var el = document.getElementById('geo-selected-tags');
var html = '';
for (var i = 0; i < selectedCountries.length; i++) {
var code = selectedCountries[i];
var name = countryName(code);
var isHU = code === 'HU' ? ' geo-tag-hu' : '';
html += '<span class="geo-tag' + isHU + '">'
+ escHtml(name) + ' (' + code + ') '
+ '<span class="geo-tag-remove" onclick="removeCountry(\'' + code + '\')">&times;</span>'
+ '</span>';
}
el.innerHTML = html;
renderAppOverrides();
}
function countryName(code) {
for (var i = 0; i < allCountries.length; i++) {
if (allCountries[i].code === code) return allCountries[i].name;
}
return code;
}
// --- Per-app overrides ---
window.addAppOverride = function() {
var sel = document.getElementById('geo-add-app-select');
var appName = sel.value;
if (!appName) return;
if (!appOverrides) appOverrides = {};
if (appOverrides[appName]) { sel.value = ''; return; }
// Default: same countries as global
appOverrides[appName] = {allowed_countries: selectedCountries.slice()};
sel.value = '';
renderAppOverrides();
};
window.removeAppOverride = function(appName) {
delete appOverrides[appName];
renderAppOverrides();
};
window.toggleAppCountry = function(appName, code, el) {
var ov = appOverrides[appName];
if (!ov) return;
var idx = ov.allowed_countries.indexOf(code);
if (idx >= 0) {
if (code === 'HU') {
openDialog({title:'{{T "settings_security.figyelem"}}', confirmLabel:'{{T "common.eltavolitas"}}',
message:'{{T "settings_security.magyarorszag_eltavolitasa_nem_ajanlott_f"}}',
onConfirm:function(){ ov.allowed_countries.splice(ov.allowed_countries.indexOf(code), 1); },
onCancel:function(){ el.checked = true; }});
return;
}
ov.allowed_countries.splice(idx, 1);
} else {
ov.allowed_countries.push(code);
}
};
function renderAppOverrides() {
var el = document.getElementById('geo-app-overrides');
if (!appOverrides || Object.keys(appOverrides).length === 0) {
el.innerHTML = '<p class="form-hint">{{T "settings_security.nincs_alkalmazas_specifikus_beallitas_mi"}}</p>';
return;
}
var html = '';
for (var appName in appOverrides) {
var ov = appOverrides[appName];
var displayName = appName;
// Try to find display name from select
var opts = document.getElementById('geo-add-app-select');
if (opts) {
for (var j = 0; j < opts.options.length; j++) {
if (opts.options[j].value === appName) { displayName = opts.options[j].text; break; }
}
}
html += '<div class="geo-app-override-row">';
html += '<strong>' + escHtml(displayName) + '</strong>';
html += '<div class="geo-selected-tags" style="flex:1;margin:0 .5rem">';
for (var i = 0; i < ov.allowed_countries.length; i++) {
var code = ov.allowed_countries[i];
html += '<span class="geo-tag geo-tag-sm">' + code + '</span>';
}
html += '</div>';
html += '<button class="btn btn-sm btn-outline" onclick="editAppOverride(\'' + appName + '\')">{{T "settings_security.szerkesztes"}}</button>';
html += '<button class="btn btn-sm btn-danger-outline" onclick="removeAppOverride(\'' + appName + '\')">{{T "common.torles"}}</button>';
html += '</div>';
}
el.innerHTML = html;
}
window.editAppOverride = function(appName) {
var ov = appOverrides[appName];
if (!ov) return;
ensureCountries(function(){
var checked = {};
for (var i = 0; i < ov.allowed_countries.length; i++) checked[ov.allowed_countries[i]] = true;
var html = '<div class="geo-edit-overlay" id="geo-edit-' + appName + '">';
html += '<h4>{{T "settings_security.engedelyezett_orszagok"}} ' + escHtml(appName) + '</h4>';
html += '<div class="geo-edit-grid">';
for (var i = 0; i < allCountries.length; i++) {
var c = allCountries[i];
html += '<label class="geo-edit-item"><input type="checkbox" value="' + c.code + '"'
+ (checked[c.code] ? ' checked' : '') + ' onchange="toggleAppCountry(\'' + appName + '\',\'' + c.code + '\',this)">'
+ ' ' + escHtml(c.name) + ' (' + c.code + ')</label>';
}
html += '</div>';
html += '<button class="btn btn-sm btn-primary" style="margin-top:.5rem" onclick="closeAppEdit(\'' + appName + '\')">{{T "settings_security.kesz"}}</button>';
html += '</div>';
document.getElementById('geo-app-overrides').innerHTML += html;
});
};
window.closeAppEdit = function(appName) {
var el = document.getElementById('geo-edit-' + appName);
if (el) el.remove();
renderAppOverrides();
};
// --- Save & Sync ---
window.saveGeoSettings = function() {
var btn = document.getElementById('btn-geo-save');
var status = document.getElementById('geo-status-msg');
btn.disabled = true;
btn.textContent = '{{T "settings_security.mentes"}}';
var payload = {
enabled: document.getElementById('geo-enabled').checked,
allowed_countries: selectedCountries
};
fetch('/api/geo/settings', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify(payload)
})
.then(function(r){return r.json()})
.then(function(d){
if (d.ok) {
status.innerHTML = '<div class="alert alert-info">' + (d.message || '{{T "settings_security.mentve"}}') + '</div>';
// Save per-app overrides
if (appOverrides && Object.keys(appOverrides).length > 0) {
saveAllAppOverrides();
}
} else {
status.innerHTML = '<div class="alert alert-error">' + (d.error || '{{T "common.hiba"}}') + '</div>';
}
})
.catch(function(err){
status.innerHTML = '<div class="alert alert-error">{{T "settings_security.halozati_hiba"}}</div>';
})
.finally(function(){
btn.disabled = false;
btn.textContent = '{{T "settings_security.mentes_es_szinkronizalas"}}';
setTimeout(function(){ status.innerHTML = ''; }, 8000);
});
};
function saveAllAppOverrides() {
for (var appName in appOverrides) {
(function(name, ov){
fetch('/api/stacks/' + name + '/geo/override', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({allowed_countries: ov.allowed_countries})
});
})(appName, appOverrides[appName]);
}
}
window.triggerGeoSync = function() {
fetch('/api/geo/sync', {method:'POST', headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){
var status = document.getElementById('geo-sync-status');
status.textContent = d.ok ? '{{T "settings_security.szinkronizalas_elinditva"}}' : (d.error || '{{T "common.hiba"}}');
setTimeout(function(){
fetch('/api/geo/status', {headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){
if (d.ok && d.data) {
var sync = d.data.last_sync || '';
var err = d.data.last_sync_error || '';
status.innerHTML = sync ? ('{{T "settings_security.utolso"}} ' + sync.substring(0,19).replace('T',' ')) : '';
if (err) status.innerHTML += ' <span class="state-text-crit">' + escHtml(err) + '</span>';
}
});
}, 5000);
});
};
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Initialize on load
if (document.getElementById('geo-enabled') && document.getElementById('geo-enabled').checked) {
ensureCountries(renderTags);
}
})();
</script>
<!-- Section: Family (v0.287.0, decisions 63/64) — the family gate's members. Passwords come from the server ONCE,
in the answer to the add/reset press; the page never contains one. -->
<div class="settings-card" id="family-card">
<h3>{{T "family_gate.card_title"}}</h3>
<p class="settings-card-desc">{{T "family_gate.card_desc"}}</p>
<div id="family-list" class="settings-grid"></div>
<p id="family-none" class="form-hint" style="display:none">{{T "family_gate.none"}}</p>
<div id="family-pw" class="alert alert-info" style="display:none">
{{T "family_gate.pw_once"}} <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
</div>
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
<input type="text" id="family-new" class="form-control" placeholder="{{T "family_gate.name_placeholder"}}" autocapitalize="none" required>
<button type="submit" class="btn btn-sm btn-primary">{{T "family_gate.add"}}</button>
</form>
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
</div>
<script>
(function () {
var T = {reset: '{{T "family_gate.reset"}}', remove: '{{T "family_gate.remove"}}',
cReset: '{{T "family_gate.confirm_reset"}}', cRemove: '{{T "family_gate.confirm_remove"}}', err: '{{T "family_gate.error"}}'};
function render(members) {
var list = document.getElementById('family-list');
list.textContent = '';
document.getElementById('family-none').style.display = members.length ? 'none' : '';
members.forEach(function (n) {
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
var val = document.createElement('span'); val.className = 'settings-value';
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
val.appendChild(b);
});
row.appendChild(label); row.appendChild(val); list.appendChild(row);
});
}
window.familyAct = function (e, action, name) {
if (e) e.preventDefault();
var err = document.getElementById('family-err'); err.style.display = 'none';
var body = new URLSearchParams(); body.set('name', name);
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-Token': '{{.CSRFToken}}', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
render(j.data.members || []);
var box = document.getElementById('family-pw');
if (j.data.password) {
document.getElementById('family-pw-name').textContent = j.data.name;
document.getElementById('family-pw-value').textContent = j.data.password;
box.style.display = '';
} else { box.style.display = 'none'; }
if (action === 'add') document.getElementById('family-new').value = '';
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
};
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
.then(function (j) { if (j.ok) render(j.data.members || []); });
})();
</script>
<!-- Section: Recovery Info -->
{{if .HasRetrievalPassword}}
<div class="settings-card">
<h3>{{T "settings_security.veszhelyzeti_informaciok"}}</h3>
<p class="settings-card-desc">
{{T "settings_security.ezeket_az_adatokat_mentse_el"}}
</p>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">{{T "common.ugyfel_azonosito"}}</span>
<span class="settings-value mono">{{.CustomerID}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Hub URL</span>
<span class="settings-value mono">{{.HubURL}}</span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "settings_security.visszaallitasi_jelszo"}}</span>
<!-- R-249: the value is NOT in this page. The old markup rendered it into a
display:none span, which hid it from the eye and from nothing else — a fetch of the
page returned the plaintext. „Megjelenít" now asks the server for it. -->
<span class="settings-value">
<span id="retrieval-pw-slot" class="mono">••••••••••••••••</span>
<button type="button" id="retrieval-pw-btn" class="btn btn-xs btn-outline" onclick="revealRetrievalPw()">{{T "settings_security.megjelenit"}}</button>
<span id="retrieval-pw-err" class="form-hint" style="display:none;color:var(--red)"></span>
</span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "settings_security.tamogatas"}}</span>
<span class="settings-value">
{{T "settings_security.felhom_eu_kapcsolat"}}
</span>
</div>
</div>
</div>
<script>
// R-249: fetch the passphrase on demand. The page itself never contains it, so this is the only
// way it reaches a browser — and it takes a live session plus the CSRF token bound to it.
function revealRetrievalPw() {
var slot = document.getElementById('retrieval-pw-slot');
var btn = document.getElementById('retrieval-pw-btn');
var err = document.getElementById('retrieval-pw-err');
err.style.display = 'none';
if (btn.dataset.shown === '1') { // „Elrejt" — drop the value out of the DOM again
slot.textContent = '••••••••••••••••';
btn.textContent = '{{T "settings_security.megjelenit"}}';
btn.dataset.shown = '';
return;
}
btn.disabled = true;
fetch('/settings/retrieval-password/reveal', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
btn.disabled = false;
if (!j.ok) {
err.textContent = j.error || '{{T "settings_security.a_visszaallitasi_jelszo_lekerese_nem"}}';
err.style.display = 'inline';
return;
}
slot.textContent = j.data.password;
btn.textContent = '{{T "settings_security.elrejt"}}';
btn.dataset.shown = '1';
}).catch(function () {
btn.disabled = false;
err.textContent = '{{T "settings_security.a_visszaallitasi_jelszo_lekerese_nem"}}';
err.style.display = 'inline';
});
}
</script>
{{end}}
{{template "layout_end" .}}
{{end}}