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:
parent
8ff5f4b1d3
commit
fe7abce95b
4 changed files with 391 additions and 105 deletions
|
|
@ -80,21 +80,18 @@ tailwind.config = {
|
|||
<div class="card card-pad">
|
||||
<template x-if="auth.needsSetup">
|
||||
<div>
|
||||
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div>
|
||||
<p class="text-xs text-t2 dark:text-t2-dark mb-4">
|
||||
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>
|
||||
<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" x-text="t('setup_hint')"></p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -102,15 +99,15 @@ tailwind.config = {
|
|||
|
||||
<template x-if="!auth.needsSetup">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -162,7 +159,7 @@ tailwind.config = {
|
|||
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? '–'"></span> images</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="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>
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
<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="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="font-bold text-wh dark:text-wh-dark text-sm">Beveiliging</span>
|
||||
<span class="badge badge-dim ml-auto" x-text="'ingelogd als ' + auth.user"></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="t('logged_in_as', {user: auth.user})"></span>
|
||||
</div>
|
||||
<div class="p-5 grid md:grid-cols-2 gap-6">
|
||||
<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">
|
||||
<template x-for="u in users" :key="u">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
|
|
@ -484,36 +481,63 @@ tailwind.config = {
|
|||
</template>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<input class="form-input" x-model="newUser.username" placeholder="Gebruikersnaam">
|
||||
<input type="password" class="form-input" x-model="newUser.password" placeholder="Wachtwoord (min. 10 tekens)">
|
||||
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> Gebruiker toevoegen</button>
|
||||
<input class="form-input" x-model="newUser.username" :placeholder="t('username')">
|
||||
<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> <span x-text="t('add_user')">Gebruiker toevoegen</span></button>
|
||||
</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">
|
||||
<input type="password" class="form-input" x-model="pwForm.current" placeholder="Huidig wachtwoord">
|
||||
<input type="password" class="form-input" x-model="pwForm.new" placeholder="Nieuw wachtwoord">
|
||||
<input type="password" class="form-input" x-model="pwForm.new2" placeholder="Nieuw wachtwoord herhalen">
|
||||
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> Wijzigen</button>
|
||||
<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="t('new_password')">
|
||||
<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> <span x-text="t('change')">Wijzigen</span></button>
|
||||
</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 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>
|
||||
|
||||
<!-- Netwerken: stacks een eigen IP-adres geven -->
|
||||
<div class="card">
|
||||
<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="font-bold text-wh dark:text-wh-dark text-sm">Netwerken</span>
|
||||
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> Netwerk</button>
|
||||
<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> <span x-text="t('network')">Netwerk</span></button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-xs text-t2 dark:text-t2-dark mb-3">
|
||||
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>
|
||||
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('networks_hint')"></p>
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
|
|
@ -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="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] 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>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
|
|
@ -553,7 +577,7 @@ tailwind.config = {
|
|||
<div class="flex flex-wrap gap-2" x-show="update.update_available">
|
||||
<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 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>
|
||||
<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>
|
||||
|
|
@ -562,27 +586,24 @@ tailwind.config = {
|
|||
<p x-show="update.update_available && !update.can_apply"
|
||||
class="text-xs text-wr flex items-start gap-1.5">
|
||||
<span class="mdi mdi-alert-outline shrink-0"></span>
|
||||
<span><span x-text="update.apply_reason"></span>
|
||||
Bijwerken kan wel via een git-tag; zie <span class="font-mono">docs/updates.md</span>.</span>
|
||||
<span><span x-text="update.apply_reason"></span> <span x-text="t('update_cannot_apply')"></span></span>
|
||||
</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">
|
||||
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>
|
||||
|
||||
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
|
||||
<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">
|
||||
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
|
||||
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
|
||||
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
|
||||
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||
<span x-show="updChannel()==='stable'">Alleen stabiele releases.</span>
|
||||
<span x-show="updChannel()==='beta'">Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"
|
||||
x-text="updChannel()==='beta' ? t('channel_beta_hint') : t('channel_stable_hint')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</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">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||
Zonder tag. Leeg = afleiden uit de draaiende container.
|
||||
</p>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_image_hint')"></p>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<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">
|
||||
</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()"
|
||||
:placeholder="settings.has_update_registry_token ? '•••••••• (ingesteld)' : 'alleen bij een privé registry'">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">Leeg laten = ongewijzigd.</p>
|
||||
: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" x-text="t('registry_token_hint')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -741,35 +760,32 @@ tailwind.config = {
|
|||
|
||||
<!-- Netwerk toevoegen -->
|
||||
<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><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><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">
|
||||
<option value="macvlan">macvlan</option>
|
||||
<option value="ipvlan">ipvlan</option>
|
||||
</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">
|
||||
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
|
||||
</div>
|
||||
</div>
|
||||
<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">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('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" x-text="t('gateway')">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></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">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||
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>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<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>
|
||||
|
||||
|
|
@ -790,28 +806,22 @@ tailwind.config = {
|
|||
|
||||
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
|
||||
<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()">
|
||||
<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">
|
||||
<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>
|
||||
</select>
|
||||
<template x-if="install.network">
|
||||
<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">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||
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>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!install.network && netList.length === 0"
|
||||
class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||
Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
|
||||
</p>
|
||||
class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
|
||||
</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">
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<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.
|
||||
</p>
|
||||
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
|
||||
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></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>
|
||||
</template>
|
||||
|
|
@ -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: { <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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
81
tests/test_i18n.py
Normal file
81
tests/test_i18n.py
Normal 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"]
|
||||
Loading…
Reference in a new issue