Vertalingen voor de nieuwe schermen + SSO-instelscherm

- De schermen die in v0.5.x zijn toegevoegd (login/setup, Beveiliging,
  Netwerken, Updates, containerpaneel, netwerkkeuze bij installeren) gebruikten
  harde Nederlandse teksten terwijl de rest van de UI via t() loopt. Op Engels
  gaf dat een mengelmoes. 80 nieuwe sleutels toegevoegd aan nl.json en en.json
  en alle literals vervangen.
- t() ondersteunt nu plaatshouders: t('port_taken', {port: 8080, suggested:
  8081}). Zo blijven zinnen heel in plaats van in losse stukjes geknipt.
- Nieuw instelscherm voor reverse-proxy-SSO onder Instellingen -> Beveiliging:
  modus lokaal/proxy/beide, identiteitsheader en de lijst met vertrouwde
  proxy-adressen. De backend (PUT /api/auth/mode) bestond al maar was alleen
  met curl te bereiken. Bij het omschakelen naar 'proxy' vraagt de UI om
  bevestiging, want die modus schakelt de lokale login uit.
- Nieuw tests/test_i18n.py: sleutelpariteit tussen nl en en, geen lege
  waarden, gelijke plaatshouders in beide talen, en een controle dat de UI
  geen t()-sleutels gebruikt die nergens gedefinieerd zijn. Die laatste vond
  meteen een fout in de eerste opzet.
- 157 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-26 19:43:30 +02:00
parent 8ff5f4b1d3
commit fe7abce95b
4 changed files with 391 additions and 105 deletions

View file

@ -80,21 +80,18 @@ tailwind.config = {
<div class="card card-pad"> <div class="card card-pad">
<template x-if="auth.needsSetup"> <template x-if="auth.needsSetup">
<div> <div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div> <div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1" x-text="t('setup_title')">Eerste account aanmaken</div>
<p class="text-xs text-t2 dark:text-t2-dark mb-4"> <p class="text-xs text-t2 dark:text-t2-dark mb-4" x-text="t('setup_hint')"></p>
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.
</p>
<form @submit.prevent="doSetup()" class="grid gap-3"> <form @submit.prevent="doSetup()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label> <div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div> <input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord (min. 10 tekens)</label> <div><label class="form-label" x-text="t('password_min')">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div> <input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
<div><label class="form-label">Wachtwoord herhalen</label> <div><label class="form-label" x-text="t('password_repeat')">Wachtwoord herhalen</label>
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div> <input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div> <div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy"> <button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span>Account aanmaken <span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span><span x-text="t('create_account')">Account aanmaken</span>
</button> </button>
</form> </form>
</div> </div>
@ -102,15 +99,15 @@ tailwind.config = {
<template x-if="!auth.needsSetup"> <template x-if="!auth.needsSetup">
<div> <div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4">Inloggen</div> <div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4" x-text="t('login')">Inloggen</div>
<form @submit.prevent="doLogin()" class="grid gap-3"> <form @submit.prevent="doLogin()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label> <div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div> <input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord</label> <div><label class="form-label" x-text="t('password')">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div> <input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div> <div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy"> <button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span>Inloggen <span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span><span x-text="t('login')">Inloggen</span>
</button> </button>
</form> </form>
</div> </div>
@ -162,7 +159,7 @@ tailwind.config = {
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div> <div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div>
</div> </div>
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button> <button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
<button @click="logout()" :title="'Uitloggen (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button> <button @click="logout()" :title="t('logout') + ' (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
</div> </div>
</div> </div>
</aside> </aside>
@ -276,7 +273,7 @@ tailwind.config = {
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden"> class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
<template x-if="cpanel[s.name] && cpanel[s.name].loading"> <template x-if="cpanel[s.name] && cpanel[s.name].loading">
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2"> <div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
<span class="mdi mdi-loading mdi-spin"></span> Laden… <span class="mdi mdi-loading mdi-spin"></span> <span x-text="t('loading')">Laden…</span>
</div> </div>
</template> </template>
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name"> <template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
@ -467,12 +464,12 @@ tailwind.config = {
<div class="card"> <div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark"> <div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span> <span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Beveiliging</span> <span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('security')">Beveiliging</span>
<span class="badge badge-dim ml-auto" x-text="'ingelogd als ' + auth.user"></span> <span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
</div> </div>
<div class="p-5 grid md:grid-cols-2 gap-6"> <div class="p-5 grid md:grid-cols-2 gap-6">
<div> <div>
<div class="form-label">Gebruikers</div> <div class="form-label" x-text="t('users')">Gebruikers</div>
<div class="space-y-1.5 mb-3"> <div class="space-y-1.5 mb-3">
<template x-for="u in users" :key="u"> <template x-for="u in users" :key="u">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
@ -484,36 +481,63 @@ tailwind.config = {
</template> </template>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<input class="form-input" x-model="newUser.username" placeholder="Gebruikersnaam"> <input class="form-input" x-model="newUser.username" :placeholder="t('username')">
<input type="password" class="form-input" x-model="newUser.password" placeholder="Wachtwoord (min. 10 tekens)"> <input type="password" class="form-input" x-model="newUser.password" :placeholder="t('password_min')">
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> Gebruiker toevoegen</button> <button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> <span x-text="t('add_user')">Gebruiker toevoegen</span></button>
</div> </div>
</div> </div>
<div> <div>
<div class="form-label">Eigen wachtwoord wijzigen</div> <div class="form-label" x-text="t('change_own_password')">Eigen wachtwoord wijzigen</div>
<div class="grid gap-2"> <div class="grid gap-2">
<input type="password" class="form-input" x-model="pwForm.current" placeholder="Huidig wachtwoord"> <input type="password" class="form-input" x-model="pwForm.current" :placeholder="t('current_password')">
<input type="password" class="form-input" x-model="pwForm.new" placeholder="Nieuw wachtwoord"> <input type="password" class="form-input" x-model="pwForm.new" :placeholder="t('new_password')">
<input type="password" class="form-input" x-model="pwForm.new2" placeholder="Nieuw wachtwoord herhalen"> <input type="password" class="form-input" x-model="pwForm.new2" :placeholder="t('new_password_repeat')">
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> Wijzigen</button> <button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> <span x-text="t('change')">Wijzigen</span></button>
</div> </div>
</div> </div>
</div> </div>
<!-- Reverse-proxy-SSO. De header wordt alleen vertrouwd vanaf de
adressen hieronder; zonder die lijst weigert de server de modus. -->
<div class="px-5 pb-5 pt-4 border-t border-bd dark:border-bd-dark">
<div class="form-label" x-text="t('auth_mode')">Hoe wordt er ingelogd?</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<button class="seg" :class="authCfg.mode==='local' ? 'seg-on' : ''"
@click="authCfg.mode='local'"><span class="mdi mdi-account-key-outline"></span> <span x-text="t('auth_mode_local')">Lokaal</span></button>
<button class="seg" :class="authCfg.mode==='proxy' ? 'seg-on' : ''"
@click="authCfg.mode='proxy'"><span class="mdi mdi-shield-link-variant-outline"></span> <span x-text="t('auth_mode_proxy')">Proxy</span></button>
<button class="seg" :class="authCfg.mode==='both' ? 'seg-on' : ''"
@click="authCfg.mode='both'"><span class="mdi mdi-check-all"></span> <span x-text="t('auth_mode_both')">Beide</span></button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-3" x-text="t('auth_mode_hint')"></p>
<div x-show="authCfg.mode !== 'local'" x-cloak class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('proxy_header')">Identiteitsheader</label>
<input class="form-input font-mono" x-model="authCfg.proxy_header" placeholder="Remote-User">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_header_hint')"></p>
</div>
<div>
<label class="form-label" x-text="t('trusted_proxies')">Vertrouwde proxy-adressen</label>
<textarea class="form-input font-mono !h-auto py-2" rows="3"
x-model="authCfg.trusted_text" placeholder="10.0.20.1&#10;172.18.0.0/16"></textarea>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('trusted_proxies_hint')"></p>
</div>
</div>
<button class="btn btn-sm mt-3" @click="saveAuthMode()">
<span class="mdi mdi-content-save-outline"></span> <span x-text="t('save')">Opslaan</span>
</button>
</div>
</div> </div>
<!-- Netwerken: stacks een eigen IP-adres geven --> <!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card"> <div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark"> <div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span> <span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Netwerken</span> <span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('networks')">Netwerken</span>
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> Netwerk</button> <button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> <span x-text="t('network')">Netwerk</span></button>
</div> </div>
<div class="p-5"> <div class="p-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-3"> <p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('networks_hint')"></p>
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. De Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.
</p>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="n in netList" :key="n.name"> <template x-for="n in netList" :key="n.name">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40"> <div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
@ -521,12 +545,12 @@ tailwind.config = {
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span> <span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
<span class="badge badge-dim" x-text="n.driver"></span> <span class="badge badge-dim" x-text="n.driver"></span>
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span> <span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip">volgend vrij: <span class="font-mono" x-text="n.next_ip"></span></span> <span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip"><span x-text="t('next_free')"></span>: <span class="font-mono" x-text="n.next_ip"></span></span>
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button> <button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
</div> </div>
</template> </template>
</div> </div>
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark">Nog geen netwerken.</div> <div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark" x-text="t('no_networks')">Nog geen netwerken.</div>
</div> </div>
</div> </div>
@ -553,7 +577,7 @@ tailwind.config = {
<div class="flex flex-wrap gap-2" x-show="update.update_available"> <div class="flex flex-wrap gap-2" x-show="update.update_available">
<button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating"> <button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating">
<span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span> <span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span>
<span x-text="updating ? 'Bezig…' : ('Nu bijwerken naar v'+update.latest)"></span> <span x-text="updating ? t('updating') : t('update_now', {version: update.latest})"></span>
</button> </button>
<a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a> <a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a>
</div> </div>
@ -562,27 +586,24 @@ tailwind.config = {
<p x-show="update.update_available && !update.can_apply" <p x-show="update.update_available && !update.can_apply"
class="text-xs text-wr flex items-start gap-1.5"> class="text-xs text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span> <span class="mdi mdi-alert-outline shrink-0"></span>
<span><span x-text="update.apply_reason"></span> <span><span x-text="update.apply_reason"></span> <span x-text="t('update_cannot_apply')"></span></span>
Bijwerken kan wel via een git-tag; zie <span class="font-mono">docs/updates.md</span>.</span>
</p> </p>
<p x-show="update.error" class="text-xs text-er" x-text="update.error"></p> <p x-show="update.error" class="text-xs text-er" x-text="update.error"></p>
<p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark"> <p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark">
Laatst gecontroleerd: <span x-text="fmtTime(update.checked_at)"></span> <span x-text="t('last_checked')"></span>: <span x-text="fmtTime(update.checked_at)"></span>
</p> </p>
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3"> <div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
<div> <div>
<label class="form-label">Kanaal</label> <label class="form-label" x-text="t('channel')">Kanaal</label>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1"> <div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''" <button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button> @click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''" <button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button> @click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
</div> </div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1"
<span x-show="updChannel()==='stable'">Alleen stabiele releases.</span> x-text="updChannel()==='beta' ? t('channel_beta_hint') : t('channel_stable_hint')"></p>
<span x-show="updChannel()==='beta'">Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.</span>
</p>
</div> </div>
<div> <div>
<label class="form-label" x-text="t('update_api_url')">Releases-API URL</label> <label class="form-label" x-text="t('update_api_url')">Releases-API URL</label>
@ -590,31 +611,29 @@ tailwind.config = {
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p>
</div> </div>
<div> <div>
<label class="form-label">Image (voor bijwerken vanuit de interface)</label> <label class="form-label" x-text="t('update_image')">Image</label>
<input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up"> <input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_image_hint')"></p>
Zonder tag. Leeg = afleiden uit de draaiende container.
</p>
</div> </div>
<div class="grid sm:grid-cols-2 gap-3"> <div class="grid sm:grid-cols-2 gap-3">
<div> <div>
<label class="form-label">Registry-gebruiker (optioneel)</label> <label class="form-label" x-text="t('registry_user')">Registry-gebruiker</label>
<input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r"> <input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r">
</div> </div>
<div> <div>
<label class="form-label">Registry-token</label> <label class="form-label" x-text="t('registry_token')">Registry-token</label>
<input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()" <input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()"
:placeholder="settings.has_update_registry_token ? '•••••••• (ingesteld)' : 'alleen bij een privé registry'"> :placeholder="settings.has_update_registry_token ? t('registry_token_set') : t('registry_token_ph')">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">Leeg laten = ongewijzigd.</p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('registry_token_hint')"></p>
</div> </div>
</div> </div>
<div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark"> <div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark">
<label class="form-label">Terugrollen</label> <label class="form-label" x-text="t('rollback')">Terugrollen</label>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-t2 dark:text-t2-dark">Vorige versie: <span class="text-xs text-t2 dark:text-t2-dark"><span x-text="t('previous_version')"></span>:
<span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span> <span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span>
<button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating"> <button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating">
<span class="mdi mdi-undo-variant"></span> Terug naar deze versie <span class="mdi mdi-undo-variant"></span> <span x-text="t('rollback_button')">Terug naar deze versie</span>
</button> </button>
</div> </div>
</div> </div>
@ -741,35 +760,32 @@ tailwind.config = {
<!-- Netwerk toevoegen --> <!-- Netwerk toevoegen -->
<div x-show="modal==='add-network'" class="modal max-w-md"> <div x-show="modal==='add-network'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> Netwerk toevoegen</div> <div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> <span x-text="t('add_network')">Netwerk toevoegen</span></div>
<div class="modal-body space-y-3"> <div class="modal-body space-y-3">
<div><label class="form-label">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div> <div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><label class="form-label">Driver</label> <div><label class="form-label" x-text="t('driver')">Driver</label>
<select class="form-input" x-model="newNet.driver"> <select class="form-input" x-model="newNet.driver">
<option value="macvlan">macvlan</option> <option value="macvlan">macvlan</option>
<option value="ipvlan">ipvlan</option> <option value="ipvlan">ipvlan</option>
</select></div> </select></div>
<div><label class="form-label">Host-interface</label> <div><label class="form-label" x-text="t('host_interface')">Host-interface</label>
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces"> <input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist> <datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><label class="form-label">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div> <div><label class="form-label" x-text="t('subnet')">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
<div><label class="form-label">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div> <div><label class="form-label" x-text="t('gateway')">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
</div> </div>
<div><label class="form-label">IP-range (optioneel)</label> <div><label class="form-label" x-text="t('ip_range')">IP-range</label>
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28"> <input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server
hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.
</p>
</div> </div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button> <button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> Aanmaken</button> <button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span></button>
</div> </div>
</div> </div>
@ -790,28 +806,22 @@ tailwind.config = {
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) --> <!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
<div> <div>
<label class="form-label">Netwerk</label> <label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()"> <select class="form-input" x-model="install.network" @change="pickIp()">
<option value="">Poorten op de host (standaard)</option> <option value="" x-text="t('ports_on_host')">Poorten op de host</option>
<template x-for="n in netList" :key="n.name"> <template x-for="n in netList" :key="n.name">
<option :value="n.name" x-text="n.name + ' — eigen IP (' + n.subnet + ')'"></option> <option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
</template> </template>
</select> </select>
<template x-if="install.network"> <template x-if="install.network">
<div class="mt-2"> <div class="mt-2">
<label class="form-label">IP-adres</label> <label class="form-label" x-text="t('ip_address')">IP-adres</label>
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240"> <input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
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).
</p>
</div> </div>
</template> </template>
<p x-show="!install.network && netList.length === 0" <p x-show="!install.network && netList.length === 0"
class="text-[11px] text-dm dark:text-dm-dark mt-1"> class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
</p>
</div> </div>
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div> <div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
@ -845,13 +855,12 @@ tailwind.config = {
<template x-if="f.type === 'str' && f.secret"> <template x-if="f.type === 'str' && f.secret">
<div class="flex gap-2"> <div class="flex gap-2">
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder"> <input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
<button type="button" class="btn-sm shrink-0" title="Genereer een willekeurige waarde" <button type="button" class="btn-sm shrink-0" :title="t('generate_value')"
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button> @click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
</div> </div>
</template> </template>
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"> <p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
Poort <span x-text="f.port_taken"></span> is al in gebruik — <span x-text="f.suggested"></span> voorgesteld. x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
</p>
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p> <p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
</div> </div>
</template> </template>
@ -1079,6 +1088,7 @@ function app() {
loginForm: { username: '', password: '', password2: '', error: '', busy: false }, loginForm: { username: '', password: '', password2: '', error: '', busy: false },
users: [], newUser: { username: '', password: '' }, users: [], newUser: { username: '', password: '' },
pwForm: { current: '', new: '', new2: '' }, pwForm: { current: '', new: '', new2: '' },
authCfg: { mode: 'local', proxy_header: 'Remote-User', trusted_text: '' },
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} } // Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
cpanel: {}, cpanel: {},
@ -1171,7 +1181,15 @@ function app() {
setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); }, setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); },
toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); }, 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 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) {} }, 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); }, 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: { const d = await this.req('/api/auth/login', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } }); username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false; 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.authenticated = true;
this.auth.user = d.user; this.auth.csrf = d.csrf_token || ''; this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false }; this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
@ -1257,13 +1275,13 @@ function app() {
async doSetup() { async doSetup() {
this.loginForm.error = ''; this.loginForm.error = '';
if (this.loginForm.password !== this.loginForm.password2) { 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; this.loginForm.busy = true;
const d = await this.req('/api/auth/setup', { method:'POST', json: { const d = await this.req('/api/auth/setup', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } }); username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false; 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.authenticated = true; this.auth.needsSetup = false;
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || ''; this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false }; this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
@ -1273,23 +1291,47 @@ function app() {
await this.req('/api/auth/logout', { method:'POST', json: {} }); await this.req('/api/auth/logout', { method:'POST', json: {} });
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = ''; 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() { async addUser() {
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser }); 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(); } if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
}, },
async delUser(name) { 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' }); 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.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
this.loadUsers(); this.loadUsers();
}, },
async changePassword() { 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: { const d = await this.req('/api/auth/password', { method:'POST', json: {
current: this.pwForm.current, new: this.pwForm.new } }); 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:'' }; if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
}, },
@ -1309,7 +1351,7 @@ function app() {
if (r.status === 401) { if (r.status === 401) {
this.auth.authenticated = false; this.auth.authenticated = false;
await this.loadAuth(); await this.loadAuth();
return { ok: false, msg: 'Sessie verlopen — log opnieuw in' }; return { ok: false, msg: this.t('session_expired') };
} }
return await r.json(); return await r.json();
} catch(e) { return { ok: false, error: e.message, msg: e.message }; } } catch(e) { return { ok: false, error: e.message, msg: e.message }; }
@ -1392,7 +1434,7 @@ function app() {
}, },
async addNetwork() { async addNetwork() {
const d = await this.rpc('/api/networks', this.newNet); 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) { if (d.ok) {
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' }; this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
this.modal = null; this.modal = null;
@ -1400,9 +1442,9 @@ function app() {
} }
}, },
async deleteNetwork(name) { 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' }); 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(); this.loadNetworks();
}, },
@ -1431,7 +1473,7 @@ function app() {
async containerAction(stack, container, action) { async containerAction(stack, container, action) {
const d = await this.rpc( const d = await this.rpc(
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {}); `/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); await this.loadContainers(stack);
this.loadStacks(); this.loadStacks();
}, },
@ -1611,8 +1653,7 @@ function app() {
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft. // pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
async applyUpdate() { async applyUpdate() {
const naar = this.update.latest_tag || this.update.latest; const naar = this.update.latest_tag || this.update.latest;
if (!confirm('Server Up bijwerken naar ' + naar + '?\n\n' if (!confirm(this.t('update_confirm', {tag: naar}))) return;
+ 'De container wordt vervangen en is enkele seconden niet bereikbaar.')) return;
this.updating = true; this.updating = true;
const d = await this.rpc('/api/update/apply', { tag: naar }); const d = await this.rpc('/api/update/apply', { tag: naar });
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; } 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()); this.pollJob(d.job_id, () => this.waitForRestart());
}, },
async rollbackUpdate() { 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; this.updating = true;
const d = await this.rpc('/api/update/rollback', {}); const d = await this.rpc('/api/update/rollback', {});
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; } 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()); this.pollJob(d.job_id, () => this.waitForRestart());
}, },
async 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. // Even wachten tot de oude container écht weg is, anders zien we hem nog.
await new Promise(r => setTimeout(r, 8000)); await new Promise(r => setTimeout(r, 8000));
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
@ -1638,7 +1679,7 @@ function app() {
const d = await r.json(); const d = await r.json();
if (d.version && d.version !== this.update.current) { if (d.version && d.version !== this.update.current) {
this.updating = false; 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); setTimeout(() => location.reload(), 1200);
return; return;
} }
@ -1647,7 +1688,7 @@ function app() {
await new Promise(r => setTimeout(r, 2000)); await new Promise(r => setTimeout(r, 2000));
} }
this.updating = false; this.updating = false;
this.toast('Herstart duurt lang — ververs de pagina zelf', 'wr'); this.toast(this.t('restart_slow'), 'wr');
}, },
async checkWizard() { async checkWizard() {

View file

@ -115,5 +115,87 @@
"view_release": "View release", "view_release": "View release",
"update_api_url": "Releases API URL", "update_api_url": "Releases API URL",
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.", "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"
} }

View file

@ -115,5 +115,87 @@
"view_release": "Bekijk release", "view_release": "Bekijk release",
"update_api_url": "Releases-API URL", "update_api_url": "Releases-API URL",
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.", "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"
} }

81
tests/test_i18n.py Normal file
View file

@ -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"]