-
Naam
+
Naam
-
Driver
+
Driver
macvlan
ipvlan
-
-
IP-range (optioneel)
+
IP-range
-
- Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server
- hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.
-
+
@@ -790,28 +806,22 @@ tailwind.config = {
-
Netwerk
+
Netwerk
- Poorten op de host (standaard)
+ Poorten op de host
-
+
-
IP-adres
+
IP-adres
-
- De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app
- is bereikbaar op zijn eigen poort. Let op: de Docker-host zelf kan een
- macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).
-
+
- Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
-
+ class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')">
Configuratie laden…
@@ -845,13 +855,12 @@ tailwind.config = {
-
-
- Poort is al in gebruik — voorgesteld.
-
+
@@ -1079,6 +1088,7 @@ function app() {
loginForm: { username: '', password: '', password2: '', error: '', busy: false },
users: [], newUser: { username: '', password: '' },
pwForm: { current: '', new: '', new2: '' },
+ authCfg: { mode: 'local', proxy_header: 'Remote-User', trusted_text: '' },
// Uitklapbaar containerpaneel per stack: {
: {open, loading, items} }
cpanel: {},
@@ -1171,7 +1181,15 @@ function app() {
setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); },
toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); },
- t(k) { return this.T[k] || k; },
+ // t('key') zoekt de vertaling op; t('key', {n: 3}) vervangt daarnaast
+ // {n}-plaatshouders, zodat een zin niet in losse stukjes hoeft.
+ t(k, vars) {
+ let s = this.T[k] || k;
+ if (vars) for (const [naam, waarde] of Object.entries(vars)) {
+ s = s.split('{' + naam + '}').join(waarde);
+ }
+ return s;
+ },
async loadLangs() { try { const d = await fetch('/api/i18n').then(r=>r.json()); this.languages = d.languages || []; } catch(e) {} },
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
@@ -1248,7 +1266,7 @@ function app() {
const d = await this.req('/api/auth/login', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false;
- if (!d.ok) { this.loginForm.error = d.msg || 'Inloggen mislukt'; return; }
+ if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
this.auth.authenticated = true;
this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
@@ -1257,13 +1275,13 @@ function app() {
async doSetup() {
this.loginForm.error = '';
if (this.loginForm.password !== this.loginForm.password2) {
- this.loginForm.error = 'De wachtwoorden komen niet overeen.'; return;
+ this.loginForm.error = this.t('passwords_differ'); return;
}
this.loginForm.busy = true;
const d = await this.req('/api/auth/setup', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false;
- if (!d.ok) { this.loginForm.error = d.msg || 'Aanmaken mislukt'; return; }
+ if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
this.auth.authenticated = true; this.auth.needsSetup = false;
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
@@ -1273,23 +1291,47 @@ function app() {
await this.req('/api/auth/logout', { method:'POST', json: {} });
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = '';
},
- async loadUsers() { const d = await this.rpc('/api/auth/users'); this.users = d.users || []; },
+ async loadUsers() {
+ const d = await this.rpc('/api/auth/users');
+ this.users = d.users || [];
+ this.authCfg = {
+ mode: d.mode || 'local',
+ proxy_header: d.proxy_header || 'Remote-User',
+ trusted_text: (d.trusted_proxies || []).join('\n'),
+ };
+ },
+ async saveAuthMode() {
+ const body = {
+ mode: this.authCfg.mode,
+ proxy_header: this.authCfg.proxy_header,
+ trusted_proxies: this.authCfg.trusted_text.split('\n').map(s => s.trim()).filter(Boolean),
+ };
+ // De server weigert 'proxy' zonder bevestiging: die modus schakelt de
+ // lokale login uit, dus je sluit jezelf buiten als de proxy niet werkt.
+ if (body.mode === 'proxy') {
+ if (!confirm(this.t('proxy_lockout_confirm'))) return;
+ body.confirm_lockout = true;
+ }
+ const d = await this.req('/api/auth/mode', { method: 'PUT', json: body });
+ this.toast(d.msg || this.t(d.ok ? 'save' : 'error'), d.ok ? 'ok' : 'er');
+ if (d.ok) this.loadUsers();
+ },
async addUser() {
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser });
- this.toast(d.msg || (d.ok ? 'Toegevoegd' : 'Fout'), d.ok ? 'ok' : 'er');
+ this.toast(d.msg || this.t(d.ok ? 'add_user' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
},
async delUser(name) {
- if (!confirm('Gebruiker ' + name + ' verwijderen?')) return;
+ if (!confirm(this.t('delete_user_confirm', {name}))) return;
const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' });
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
this.loadUsers();
},
async changePassword() {
- if (this.pwForm.new !== this.pwForm.new2) { this.toast('Wachtwoorden komen niet overeen', 'er'); return; }
+ if (this.pwForm.new !== this.pwForm.new2) { this.toast(this.t('passwords_differ'), 'er'); return; }
const d = await this.req('/api/auth/password', { method:'POST', json: {
current: this.pwForm.current, new: this.pwForm.new } });
- this.toast(d.msg || (d.ok ? 'Gewijzigd' : 'Fout'), d.ok ? 'ok' : 'er');
+ this.toast(d.msg || this.t(d.ok ? 'change' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
},
@@ -1309,7 +1351,7 @@ function app() {
if (r.status === 401) {
this.auth.authenticated = false;
await this.loadAuth();
- return { ok: false, msg: 'Sessie verlopen — log opnieuw in' };
+ return { ok: false, msg: this.t('session_expired') };
}
return await r.json();
} catch(e) { return { ok: false, error: e.message, msg: e.message }; }
@@ -1392,7 +1434,7 @@ function app() {
},
async addNetwork() {
const d = await this.rpc('/api/networks', this.newNet);
- this.toast(d.msg || (d.ok ? 'Aangemaakt' : 'Fout'), d.ok ? 'ok' : 'er');
+ this.toast(d.msg || this.t(d.ok ? 'add' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) {
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
this.modal = null;
@@ -1400,9 +1442,9 @@ function app() {
}
},
async deleteNetwork(name) {
- if (!confirm('Netwerk ' + name + ' verwijderen?')) return;
+ if (!confirm(this.t('delete_network_confirm', {name}))) return;
const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' });
- this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
+ this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
this.loadNetworks();
},
@@ -1431,7 +1473,7 @@ function app() {
async containerAction(stack, container, action) {
const d = await this.rpc(
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {});
- this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || 'Fout'), d.ok ? 'ok' : 'er');
+ this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er');
await this.loadContainers(stack);
this.loadStacks();
},
@@ -1611,8 +1653,7 @@ function app() {
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
async applyUpdate() {
const naar = this.update.latest_tag || this.update.latest;
- if (!confirm('Server Up bijwerken naar ' + naar + '?\n\n'
- + 'De container wordt vervangen en is enkele seconden niet bereikbaar.')) return;
+ if (!confirm(this.t('update_confirm', {tag: naar}))) return;
this.updating = true;
const d = await this.rpc('/api/update/apply', { tag: naar });
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
@@ -1620,7 +1661,7 @@ function app() {
this.pollJob(d.job_id, () => this.waitForRestart());
},
async rollbackUpdate() {
- if (!confirm('Terugrollen naar ' + this.update.previous_tag + '?')) return;
+ if (!confirm(this.t('rollback_confirm', {tag: this.update.previous_tag}))) return;
this.updating = true;
const d = await this.rpc('/api/update/rollback', {});
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
@@ -1628,7 +1669,7 @@ function app() {
this.pollJob(d.job_id, () => this.waitForRestart());
},
async waitForRestart() {
- this.toast('Server Up herstart…', 'in');
+ this.toast(this.t('restarting'), 'in');
// Even wachten tot de oude container écht weg is, anders zien we hem nog.
await new Promise(r => setTimeout(r, 8000));
for (let i = 0; i < 60; i++) {
@@ -1638,7 +1679,7 @@ function app() {
const d = await r.json();
if (d.version && d.version !== this.update.current) {
this.updating = false;
- this.toast('Bijgewerkt naar v' + d.version, 'ok');
+ this.toast(this.t('updated_to', {version: d.version}), 'ok');
setTimeout(() => location.reload(), 1200);
return;
}
@@ -1647,7 +1688,7 @@ function app() {
await new Promise(r => setTimeout(r, 2000));
}
this.updating = false;
- this.toast('Herstart duurt lang — ververs de pagina zelf', 'wr');
+ this.toast(this.t('restart_slow'), 'wr');
},
async checkWizard() {
diff --git a/server-up/translations/en.json b/server-up/translations/en.json
index 0848dbc..d08e003 100644
--- a/server-up/translations/en.json
+++ b/server-up/translations/en.json
@@ -115,5 +115,87 @@
"view_release": "View release",
"update_api_url": "Releases API URL",
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.",
- "include_prerelease": "Include pre-releases (beta)"
+ "include_prerelease": "Include pre-releases (beta)",
+
+ "login": "Sign in",
+ "login_failed": "Sign-in failed",
+ "logout": "Sign out",
+ "session_expired": "Session expired — please sign in again",
+ "setup_title": "Create the first account",
+ "setup_hint": "Server Up manages Docker on this machine. Create an administrator account now — until you do, anyone who can reach this page can claim it.",
+ "create_account": "Create account",
+ "username": "Username",
+ "password": "Password",
+ "password_min": "Password (min. 10 characters)",
+ "password_repeat": "Repeat password",
+ "passwords_differ": "The passwords do not match.",
+
+ "security": "Security",
+ "logged_in_as": "signed in as {user}",
+ "users": "Users",
+ "add_user": "Add user",
+ "delete_user_confirm": "Delete user {name}?",
+ "change_own_password": "Change your password",
+ "current_password": "Current password",
+ "new_password": "New password",
+ "new_password_repeat": "Repeat new password",
+ "change": "Change",
+
+ "auth_mode": "How do people sign in?",
+ "auth_mode_local": "Local login",
+ "auth_mode_proxy": "Reverse proxy",
+ "auth_mode_both": "Both",
+ "auth_mode_hint": "With a reverse proxy (Authelia, Authentik, Cloudflare Access) the identity comes from a header. Pick 'Both' to test that without losing your local login.",
+ "proxy_header": "Identity header",
+ "proxy_header_hint": "Usually Remote-User. Make sure your proxy strips this header from incoming requests and sets it itself.",
+ "trusted_proxies": "Trusted proxy addresses",
+ "trusted_proxies_hint": "One per line, IP or CIDR. The header is only trusted from these addresses — without this list anyone could impersonate an administrator.",
+ "proxy_lockout_confirm": "In 'Reverse proxy' mode the local login is disabled. Are you sure your proxy works?",
+ "save": "Save",
+
+ "networks": "Networks",
+ "networks_hint": "A macvlan or ipvlan network gives a stack its own IP address on your LAN instead of ports on the host. No more port conflicts, and you can firewall per app. Note: the Docker host itself cannot reach macvlan containers without a shim interface.",
+ "add_network": "Add network",
+ "network": "Network",
+ "no_networks": "No networks yet.",
+ "delete_network_confirm": "Delete network {name}?",
+ "driver": "Driver",
+ "host_interface": "Host interface",
+ "subnet": "Subnet",
+ "gateway": "Gateway",
+ "ip_range": "IP range (optional)",
+ "ip_range_hint": "Limits the addresses Docker hands out to a block you reserved in your DHCP server. Without a range Docker uses the whole subnet.",
+ "next_free": "next free",
+ "ports_on_host": "Ports on the host (default)",
+ "own_ip": "own IP",
+ "ip_address": "IP address",
+ "own_ip_hint": "The stack gets this address on your LAN. Port mappings are dropped — the app is reachable on its own port. The Docker host itself cannot reach a macvlan container without a shim interface (see docs/netwerken.md).",
+ "no_networks_hint": "Want to give stacks their own IP? Add a network first under Settings → Networks.",
+
+ "loading": "Loading…",
+ "generate_value": "Generate a random value",
+ "port_taken": "Port {port} is already in use — {suggested} suggested.",
+
+ "channel": "Channel",
+ "channel_stable_hint": "Stable releases only.",
+ "channel_beta_hint": "Also pre-releases (e.g. v0.5.10-beta1). Useful for testing, less predictable.",
+ "update_now": "Update now to v{version}",
+ "updating": "Working…",
+ "update_confirm": "Update Server Up to {tag}?\n\nThe container will be replaced and unreachable for a few seconds.",
+ "update_cannot_apply": "You can still update with a git tag; see docs/updates.md.",
+ "update_image": "Image (for updating from the interface)",
+ "update_image_hint": "Without a tag. Empty = derive from the running container.",
+ "registry_user": "Registry user (optional)",
+ "registry_token": "Registry token",
+ "registry_token_set": "•••••••• (set)",
+ "registry_token_ph": "only for a private registry",
+ "registry_token_hint": "Leave empty = unchanged.",
+ "last_checked": "Last checked",
+ "rollback": "Roll back",
+ "previous_version": "Previous version",
+ "rollback_button": "Back to this version",
+ "rollback_confirm": "Roll back to {tag}?",
+ "restarting": "Server Up is restarting…",
+ "updated_to": "Updated to v{version}",
+ "restart_slow": "Restart is taking a while — please refresh the page"
}
diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json
index bc37eea..f605d8a 100644
--- a/server-up/translations/nl.json
+++ b/server-up/translations/nl.json
@@ -115,5 +115,87 @@
"view_release": "Bekijk release",
"update_api_url": "Releases-API URL",
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.",
- "include_prerelease": "Pre-releases (beta) meenemen"
+ "include_prerelease": "Pre-releases (beta) meenemen",
+
+ "login": "Inloggen",
+ "login_failed": "Inloggen mislukt",
+ "logout": "Uitloggen",
+ "session_expired": "Sessie verlopen — log opnieuw in",
+ "setup_title": "Eerste account aanmaken",
+ "setup_hint": "Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan — zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.",
+ "create_account": "Account aanmaken",
+ "username": "Gebruikersnaam",
+ "password": "Wachtwoord",
+ "password_min": "Wachtwoord (min. 10 tekens)",
+ "password_repeat": "Wachtwoord herhalen",
+ "passwords_differ": "De wachtwoorden komen niet overeen.",
+
+ "security": "Beveiliging",
+ "logged_in_as": "ingelogd als {user}",
+ "users": "Gebruikers",
+ "add_user": "Gebruiker toevoegen",
+ "delete_user_confirm": "Gebruiker {name} verwijderen?",
+ "change_own_password": "Eigen wachtwoord wijzigen",
+ "current_password": "Huidig wachtwoord",
+ "new_password": "Nieuw wachtwoord",
+ "new_password_repeat": "Nieuw wachtwoord herhalen",
+ "change": "Wijzigen",
+
+ "auth_mode": "Hoe wordt er ingelogd?",
+ "auth_mode_local": "Lokale login",
+ "auth_mode_proxy": "Reverse proxy",
+ "auth_mode_both": "Beide",
+ "auth_mode_hint": "Met een reverse proxy (Authelia, Authentik, Cloudflare Access) komt de identiteit uit een header. Kies 'Beide' om dat te testen zonder je lokale login te verliezen.",
+ "proxy_header": "Identiteitsheader",
+ "proxy_header_hint": "Meestal Remote-User. Zorg dat je proxy deze header wist en zelf opnieuw zet.",
+ "trusted_proxies": "Vertrouwde proxy-adressen",
+ "trusted_proxies_hint": "Eén per regel, IP of CIDR. Alleen vanaf deze adressen wordt de header vertrouwd — zonder deze lijst kan iedereen zich voordoen als beheerder.",
+ "proxy_lockout_confirm": "In modus 'Reverse proxy' vervalt de lokale login. Weet je zeker dat je proxy werkt?",
+ "save": "Opslaan",
+
+ "networks": "Netwerken",
+ "networks_hint": "Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app firewallen. Let op: de Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.",
+ "add_network": "Netwerk toevoegen",
+ "network": "Netwerk",
+ "no_networks": "Nog geen netwerken.",
+ "delete_network_confirm": "Netwerk {name} verwijderen?",
+ "driver": "Driver",
+ "host_interface": "Host-interface",
+ "subnet": "Subnet",
+ "gateway": "Gateway",
+ "ip_range": "IP-range (optioneel)",
+ "ip_range_hint": "Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.",
+ "next_free": "volgend vrij",
+ "ports_on_host": "Poorten op de host (standaard)",
+ "own_ip": "eigen IP",
+ "ip_address": "IP-adres",
+ "own_ip_hint": "De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app is bereikbaar op zijn eigen poort. De Docker-host zelf kan een macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).",
+ "no_networks_hint": "Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.",
+
+ "loading": "Laden…",
+ "generate_value": "Genereer een willekeurige waarde",
+ "port_taken": "Poort {port} is al in gebruik — {suggested} voorgesteld.",
+
+ "channel": "Kanaal",
+ "channel_stable_hint": "Alleen stabiele releases.",
+ "channel_beta_hint": "Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.",
+ "update_now": "Nu bijwerken naar v{version}",
+ "updating": "Bezig…",
+ "update_confirm": "Server Up bijwerken naar {tag}?\n\nDe container wordt vervangen en is enkele seconden niet bereikbaar.",
+ "update_cannot_apply": "Bijwerken kan wel via een git-tag; zie docs/updates.md.",
+ "update_image": "Image (voor bijwerken vanuit de interface)",
+ "update_image_hint": "Zonder tag. Leeg = afleiden uit de draaiende container.",
+ "registry_user": "Registry-gebruiker (optioneel)",
+ "registry_token": "Registry-token",
+ "registry_token_set": "•••••••• (ingesteld)",
+ "registry_token_ph": "alleen bij een privé registry",
+ "registry_token_hint": "Leeg laten = ongewijzigd.",
+ "last_checked": "Laatst gecontroleerd",
+ "rollback": "Terugrollen",
+ "previous_version": "Vorige versie",
+ "rollback_button": "Terug naar deze versie",
+ "rollback_confirm": "Terugrollen naar {tag}?",
+ "restarting": "Server Up herstart…",
+ "updated_to": "Bijgewerkt naar v{version}",
+ "restart_slow": "Herstart duurt lang — ververs de pagina zelf"
}
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..9b91e3f
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,81 @@
+"""Vertalingen: nl en en moeten dezelfde sleutels hebben, en de UI mag geen
+sleutels gebruiken die nergens gedefinieerd zijn.
+
+Zonder deze test loopt de vertaling stilletjes scheef zodra iemand een scherm
+toevoegt — precies wat er bij de auth-, netwerk- en update-schermen gebeurde.
+"""
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+WORTEL = Path(__file__).resolve().parent.parent / "server-up"
+VERTALINGEN = WORTEL / "translations"
+TEMPLATE = WORTEL / "templates" / "index.html"
+
+
+def _laad(code: str) -> dict:
+ data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
+ return {k: v for k, v in data.items() if not k.startswith("_")}
+
+
+def test_nl_en_en_hebben_dezelfde_sleutels():
+ nl, en = _laad("nl"), _laad("en")
+ alleen_nl = sorted(set(nl) - set(en))
+ alleen_en = sorted(set(en) - set(nl))
+ assert not alleen_nl, f"Ontbreekt in en.json: {alleen_nl}"
+ assert not alleen_en, f"Ontbreekt in nl.json: {alleen_en}"
+
+
+@pytest.mark.parametrize("code", ["nl", "en"])
+def test_geen_lege_vertalingen(code):
+ leeg = [k for k, v in _laad(code).items() if not str(v).strip()]
+ assert not leeg, f"Lege waarden in {code}.json: {leeg}"
+
+
+@pytest.mark.parametrize("code", ["nl", "en"])
+def test_meta_is_compleet(code):
+ data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
+ meta = data.get("_meta", {})
+ assert meta.get("code") == code
+ assert meta.get("name")
+
+
+def test_plaatshouders_komen_overeen():
+ """`{user}` in het Nederlands moet ook in het Engels staan, anders valt er
+ bij het vertalen een variabele weg."""
+ nl, en = _laad("nl"), _laad("en")
+ for sleutel, tekst in nl.items():
+ vars_nl = set(re.findall(r"\{(\w+)\}", str(tekst)))
+ vars_en = set(re.findall(r"\{(\w+)\}", str(en.get(sleutel, ""))))
+ assert vars_nl == vars_en, f"{sleutel}: nl={vars_nl} en={vars_en}"
+
+
+def test_ui_gebruikt_alleen_bestaande_sleutels():
+ regels = TEMPLATE.read_text(encoding="utf-8").splitlines()
+ # Commentaarregels overslaan: die noemen t('key') als voorbeeld.
+ html = "\n".join(r for r in regels if not r.lstrip().startswith(("//", "*", "/*")))
+ gebruikt = set(re.findall(r"\bt\('([a-z0-9_]+)'", html))
+ bekend = set(_laad("nl"))
+ onbekend = sorted(gebruikt - bekend)
+ assert not onbekend, f"t() verwijst naar niet-bestaande sleutels: {onbekend}"
+
+
+def test_nieuwe_schermen_zijn_vertaald():
+ """Steekproef op de schermen die in v0.5.x zijn toegevoegd."""
+ nl = _laad("nl")
+ for sleutel in ("login", "setup_title", "security", "auth_mode",
+ "trusted_proxies", "networks", "own_ip_hint", "channel",
+ "update_now", "rollback", "port_taken", "loading"):
+ assert sleutel in nl, f"vertaalsleutel ontbreekt: {sleutel}"
+
+
+def test_i18n_module_leest_beide_talen(env):
+ from core import i18n
+ i18n.load()
+ codes = {t["code"] for t in i18n.available()}
+ assert {"nl", "en"} <= codes
+ # strings() valt terug op het Engels voor ontbrekende sleutels.
+ assert i18n.strings("nl")["login"]
+ assert i18n.strings("en")["login"]