feat(2a): Erlaubte Sprachen pro Nutzer (Admin-Vorgabe)

Der Admin schaltet pro Nutzer Sprachen frei (Toggle-Chips in der
Nutzerverwaltung). Die Haupt-App zeigt dann nur diese Sprachen; bei genau
einer erlaubten Sprache wird das Sprachmenü ausgeblendet (kein Stress).

- admin.py: PUT /admin/users/{id}/prefs (allowed_languages), list_users
  liefert allowed_languages mit.
- dependencies.py: resolve_route klemmt die Sprache auf eine erlaubte
  (Backend-Durchsetzung, nicht nur UI).
- app.js: applyAllowedLanguages (Haupt-UI-Filter) + Sprach-Chips je Nutzer
  im Admin. app.js v=44.
- Tests: Klemmung + Admin-Endpunkt. 213 passed.

Login-/nginx-Umbau (Ein-Klick-Link) folgt nach separater Design-Abstimmung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 04:06:09 +02:00
commit cc354938df
7 changed files with 103 additions and 6 deletions

View file

@ -319,6 +319,25 @@ let mePromise = null;
let currentUserId = null;
const sessionKey = (uid) => "va-session-" + uid;
// Sprachen (Code -> Kurzlabel) für Filter + Admin-Auswahl.
const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"],
["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]];
// Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt.
// Genau eine erlaubte Sprache -> Menü ausblenden (kein Stress für den Nutzer).
function applyAllowedLanguages(prefs) {
if (!langSel) return;
const allowed = String((prefs || {}).allowed_languages || "")
.split(",").map((s) => s.trim()).filter(Boolean);
Array.from(langSel.options).forEach((opt) => {
opt.hidden = allowed.length > 0 && !allowed.includes(opt.value);
});
let val = (prefs || {}).language || "de";
if (allowed.length && !allowed.includes(val)) val = allowed[0];
langSel.value = val;
langSel.classList.toggle("hidden", allowed.length === 1);
}
// ---------- Identitaet / Menue ----------
function loadMe() {
mePromise = (async () => {
@ -343,9 +362,7 @@ function loadMe() {
}
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
const prefs = me.prefs || {};
if (langSel) {
langSel.value = prefs.language || "de";
}
applyAllowedLanguages(prefs);
applyPlaybackChoice(prefs.tts_provider);
applyGenderChoice(prefs.voice_gender || 'f');
return me;
@ -1043,6 +1060,12 @@ function buildUserCard(u) {
</form>
</div>
<!-- Erlaubte Sprachen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Erlaubte Sprachen <span class="text-slate-400">(leer = alle)</span>:</p>
<div class="lang-allow flex flex-wrap gap-1"></div>
</div>
<!-- Erinnerungen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
@ -1061,6 +1084,26 @@ function buildUserCard(u) {
const uid = u.user_id;
// Erlaubte Sprachen (Toggle-Chips) — Admin schaltet Sprachen pro Nutzer frei.
const allowWrap = card.querySelector(".lang-allow");
let allowed = String(u.allowed_languages || "").split(",").map((s) => s.trim()).filter(Boolean);
function renderLangChips() {
allowWrap.innerHTML = ALL_LANGS.map(([code, label]) => {
const on = allowed.includes(code);
return `<button type="button" data-lang="${code}" class="lang-chip text-xs rounded-md border px-2 py-1 transition-colors ${on ? "bg-blue-600 text-white border-blue-600" : "border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"}">${label}</button>`;
}).join("");
allowWrap.querySelectorAll(".lang-chip").forEach((b) =>
b.addEventListener("click", async () => {
const code = b.dataset.lang;
allowed = allowed.includes(code) ? allowed.filter((c) => c !== code) : [...allowed, code];
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT",
{ allowed_languages: allowed.join(",") });
if (res) renderLangChips();
})
);
}
renderLangChips();
// Umbenennen
card.querySelector(".btn-rename").addEventListener("click", () =>
card.querySelector(".rename-box").classList.toggle("hidden")