diff --git a/lxc-frontend/app/api/auth.py b/lxc-frontend/app/api/auth.py index a4f2426..094449b 100644 --- a/lxc-frontend/app/api/auth.py +++ b/lxc-frontend/app/api/auth.py @@ -71,6 +71,33 @@ def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: return _user_dict(user) +class ChangePasswordIn(BaseModel): + current_password: str = Field(min_length=1, max_length=200) + new_password: str = Field(min_length=8, max_length=200) + + +@router.post("/me/password") +def change_password( + payload: ChangePasswordIn, + user: User = Depends(current_user), + session: Session = Depends(get_session), +): + """User ändert eigenes Passwort. + + Aktuelles Passwort muss korrekt sein — verhindert Übernahme, wenn jemand + eine offene Browser-Session findet. Admin-Reset läuft weiter über + /api/admin/users//password (ohne current_password). + """ + if not verify_password(payload.current_password, user.password_hash): + log.warning("Password-Change abgelehnt für user=%s (aktuelles Passwort falsch)", user.username) + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort falsch") + user.password_hash = hash_password(payload.new_password) + session.add(user) + session.commit() + log.info("Password geändert für user=%s (id=%d)", user.username, user.id) + return {"ok": True} + + @router.get("/needs-setup") def needs_setup(session: Session = Depends(get_session)): """Vor Login prüfen ob noch kein User existiert (erstes Deployment).""" diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index e5a2cd9..512029b 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -33,10 +33,24 @@ const usersBody = document.getElementById("users-body"); const profileLabel = document.getElementById("profile-label"); const profileSelect = document.getElementById("profile-select"); -const headerProfileWrap = document.getElementById("header-profile-wrap"); -const headerProfile = document.getElementById("header-profile"); -const headerModelWrap = document.getElementById("header-model-wrap"); -const headerModel = document.getElementById("header-model"); + +// Settings-Subpage +const settingsBtn = document.getElementById("settings-btn"); +const settingsView = document.getElementById("settings-view"); +const settingsBackBtn = document.getElementById("settings-back"); +const settingsUsername = document.getElementById("settings-username"); +const settingsProfile = document.getElementById("settings-profile"); +const settingsProfileMsg = document.getElementById("settings-profile-msg"); +const settingsModelSection = document.getElementById("settings-model-section"); +const settingsModel = document.getElementById("settings-model"); +const settingsModelMsg = document.getElementById("settings-model-msg"); +const settingsPwForm = document.getElementById("settings-pw-form"); +const pwCurrent = document.getElementById("pw-current"); +const pwNew = document.getElementById("pw-new"); +const pwConfirm = document.getElementById("pw-confirm"); +const pwSaveBtn = document.getElementById("pw-save"); +const settingsPwMsg = document.getElementById("settings-pw-msg"); +const mainView = document.getElementById("main-view"); const jobsCards = document.getElementById("jobs-cards"); const adminToggle = document.getElementById("admin-toggle"); @@ -188,6 +202,9 @@ function enterApp() { authOverlay.classList.add("hidden"); appMain.classList.remove("hidden"); userBar.classList.remove("hidden"); + // Falls vorher in Settings → wieder Main zeigen + settingsView.classList.add("hidden"); + mainView.classList.remove("hidden"); userName.textContent = currentUser.username; userAdminBadge.classList.toggle("hidden", !currentUser.is_admin); adminCard.classList.toggle("hidden", !currentUser.is_admin); @@ -215,12 +232,12 @@ function profileLabelOf(name) { } function applyProfileUI() { - // Upload-Dropdown und Header-Selector nur sichtbar wenn ≥2 Profile. + // Upload-Dropdown nur sichtbar wenn ≥2 Profile (sonst sinnlos). const showDropdown = availableProfiles.length > 1; profileLabel.classList.toggle("hidden", !showDropdown); - headerProfileWrap.classList.toggle("hidden", !showDropdown); - for (const sel of [profileSelect, headerProfile]) { + for (const sel of [profileSelect, settingsProfile]) { + if (!sel) continue; sel.innerHTML = ""; availableProfiles.forEach((p) => { const o = document.createElement("option"); @@ -231,7 +248,7 @@ function applyProfileUI() { } const def = currentUser.default_profile || "meeting"; profileSelect.value = def; - headerProfile.value = def; + settingsProfile.value = def; } async function loadModels() { @@ -253,67 +270,126 @@ function modelLabelOf(name) { } function applyModelUI() { - // Wenn der Mac keine Modelle meldet, Dropdown verstecken — wir wollen - // keinen leeren Selector zeigen, der dem User vortäuscht, er hätte Wahl. + // Wenn der Mac keine Modelle meldet, Section verstecken — sonst leerer Selector. const showDropdown = availableModels.length > 0; - headerModelWrap.classList.toggle("hidden", !showDropdown); + settingsModelSection.classList.toggle("hidden", !showDropdown); if (!showDropdown) return; - headerModel.innerHTML = ""; - // Erste Option: kein Override → Worker-Default + settingsModel.innerHTML = ""; const opt0 = document.createElement("option"); opt0.value = ""; opt0.textContent = workerDefaultModel ? `Worker-Default (${workerDefaultModel})` : "Worker-Default"; - headerModel.appendChild(opt0); + settingsModel.appendChild(opt0); availableModels.forEach((m) => { const o = document.createElement("option"); o.value = m.name; const sizeGb = m.size ? ` — ${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : ""; o.textContent = `${m.name}${sizeGb}`; - headerModel.appendChild(o); + settingsModel.appendChild(o); }); - headerModel.value = currentUser.default_model || ""; + settingsModel.value = currentUser.default_model || ""; } -headerModel.addEventListener("change", async () => { - const newDefault = headerModel.value; - headerModel.disabled = true; +// ─── Settings-Subpage: Toggle + Save-Handler ──────────────────────────────── +function showSettings() { + settingsUsername.textContent = currentUser.username; + applyProfileUI(); + applyModelUI(); + settingsProfileMsg.textContent = ""; + settingsModelMsg.textContent = ""; + settingsPwMsg.textContent = ""; + settingsPwForm.reset(); + mainView.classList.add("hidden"); + settingsView.classList.remove("hidden"); +} + +function hideSettings() { + settingsView.classList.add("hidden"); + mainView.classList.remove("hidden"); +} + +function flashMsg(el, text, kind) { + el.textContent = text; + el.className = `msg ${kind}`; + if (kind === "ok") setTimeout(() => { if (el.textContent === text) el.textContent = ""; }, 3000); +} + +settingsBtn.addEventListener("click", (ev) => { ev.preventDefault(); showSettings(); }); +settingsBackBtn.addEventListener("click", (ev) => { ev.preventDefault(); hideSettings(); }); + +// Default-Profile: sofort speichern bei change. +settingsProfile.addEventListener("change", async () => { + const v = settingsProfile.value; + settingsProfile.disabled = true; try { const r = await api("/api/auth/me", { method: "PATCH", - body: JSON.stringify({ default_model: newDefault }), + body: JSON.stringify({ default_profile: v }), }); if (!r.ok) throw new Error("save failed"); currentUser = await r.json(); + profileSelect.value = currentUser.default_profile; + flashMsg(settingsProfileMsg, `Standard-Profil ist jetzt: ${profileLabelOf(currentUser.default_profile)}`, "ok"); } catch (e) { - alert("Konnte Standard-Modell nicht speichern."); - headerModel.value = currentUser.default_model || ""; + flashMsg(settingsProfileMsg, "Konnte Standard-Profil nicht speichern.", "err"); + settingsProfile.value = currentUser.default_profile || "meeting"; } finally { - headerModel.disabled = false; + settingsProfile.disabled = false; } }); -// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert. -headerProfile.addEventListener("change", async () => { - const newDefault = headerProfile.value; - headerProfile.disabled = true; +// Default-Modell: sofort speichern bei change. +settingsModel.addEventListener("change", async () => { + const v = settingsModel.value; + settingsModel.disabled = true; try { const r = await api("/api/auth/me", { method: "PATCH", - body: JSON.stringify({ default_profile: newDefault }), + body: JSON.stringify({ default_model: v }), }); if (!r.ok) throw new Error("save failed"); currentUser = await r.json(); - // Upload-Dropdown synchron mitziehen - profileSelect.value = currentUser.default_profile; + flashMsg(settingsModelMsg, + `Standard-Modell ist jetzt: ${currentUser.default_model || "(Worker-Default)"}`, "ok"); } catch (e) { - alert("Konnte Standard-Profil nicht speichern."); - headerProfile.value = currentUser.default_profile || "meeting"; + flashMsg(settingsModelMsg, "Konnte Standard-Modell nicht speichern.", "err"); + settingsModel.value = currentUser.default_model || ""; } finally { - headerProfile.disabled = false; + settingsModel.disabled = false; + } +}); + +// Passwort-Form: Bestätigung clientseitig prüfen, dann an /me/password. +settingsPwForm.addEventListener("submit", async (ev) => { + ev.preventDefault(); + if (pwNew.value !== pwConfirm.value) { + flashMsg(settingsPwMsg, "Neue Passwörter stimmen nicht überein.", "err"); + pwConfirm.focus(); + return; + } + pwSaveBtn.disabled = true; + try { + const r = await api("/api/auth/me/password", { + method: "POST", + body: JSON.stringify({ + current_password: pwCurrent.value, + new_password: pwNew.value, + }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + flashMsg(settingsPwMsg, d.detail || "Konnte Passwort nicht ändern.", "err"); + return; + } + flashMsg(settingsPwMsg, "Passwort geändert.", "ok"); + settingsPwForm.reset(); + } catch (e) { + flashMsg(settingsPwMsg, "Netzwerkfehler beim Speichern.", "err"); + } finally { + pwSaveBtn.disabled = false; } }); diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html index 1d9d57c..9eb4f76 100644 --- a/lxc-frontend/app/static/index.html +++ b/lxc-frontend/app/static/index.html @@ -27,16 +27,7 @@ Angemeldet als - - + @@ -64,6 +55,50 @@
+ + + + +

Neue Aufnahme hochladen

@@ -178,6 +213,7 @@
+