feat(settings): eigene Unterseite für User-Einstellungen + Passwort-Wechsel

Header: Profil- und Modell-Dropdowns rausgenommen, dafür ein
"⚙ Einstellungen"-Link. Klick öffnet eine eigene Settings-View
(toggled die main-View aus) mit drei Sektionen:

- Standard-Profil (sofort speichernd)
- Standard-LLM-Modell (sofort speichernd, leer = Worker-Default)
- Passwort ändern (aktuelles + neues + Bestätigung)

Neuer Endpoint POST /api/auth/me/password — verlangt zwingend das
aktuelle Passwort (verhindert Übernahme via offene Browser-Session).
Admin-Reset über /api/admin/users/<id>/password bleibt unberührt.

Layout: Settings-View ist eine Card mit max-width 640px, jede
Sektion durch Trennlinie abgegrenzt. Inline-Status-Meldungen
("Standard-Profil ist jetzt: …") direkt unter den Controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-05-16 20:40:28 +00:00
parent f03d46afb5
commit 7679efa7e4
4 changed files with 231 additions and 43 deletions

View File

@ -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/<id>/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)."""

View File

@ -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();
} catch (e) {
alert("Konnte Standard-Modell nicht speichern.");
headerModel.value = currentUser.default_model || "";
} finally {
headerModel.disabled = false;
}
});
// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert.
headerProfile.addEventListener("change", async () => {
const newDefault = headerProfile.value;
headerProfile.disabled = true;
try {
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_profile: newDefault }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
// Upload-Dropdown synchron mitziehen
profileSelect.value = currentUser.default_profile;
flashMsg(settingsProfileMsg, `Standard-Profil ist jetzt: ${profileLabelOf(currentUser.default_profile)}`, "ok");
} catch (e) {
alert("Konnte Standard-Profil nicht speichern.");
headerProfile.value = currentUser.default_profile || "meeting";
flashMsg(settingsProfileMsg, "Konnte Standard-Profil nicht speichern.", "err");
settingsProfile.value = currentUser.default_profile || "meeting";
} finally {
headerProfile.disabled = false;
settingsProfile.disabled = false;
}
});
// 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_model: v }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
flashMsg(settingsModelMsg,
`Standard-Modell ist jetzt: ${currentUser.default_model || "(Worker-Default)"}`, "ok");
} catch (e) {
flashMsg(settingsModelMsg, "Konnte Standard-Modell nicht speichern.", "err");
settingsModel.value = currentUser.default_model || "";
} finally {
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;
}
});

View File

@ -27,16 +27,7 @@
<span class="muted desktop-only">Angemeldet als</span>
<strong id="user-name"></strong>
<span id="user-admin-badge" class="badge admin-badge hidden">Admin</span>
<span id="header-profile-wrap" class="header-profile-wrap hidden">
<span class="muted desktop-only">·</span>
<span class="muted desktop-only">Profil:</span>
<select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select>
</span>
<span id="header-model-wrap" class="header-profile-wrap hidden">
<span class="muted desktop-only">·</span>
<span class="muted desktop-only">Modell:</span>
<select id="header-model" class="header-profile" title="Standard-LLM — wird sofort gespeichert. Leer = Worker-Default."></select>
</span>
<button type="button" id="settings-btn" class="btn-link" title="Eigene Einstellungen — Profil, Modell, Passwort">⚙ Einstellungen</button>
<button type="button" id="logout-btn" class="btn-link">Abmelden</button>
</div>
</div>
@ -64,6 +55,50 @@
</div>
<main id="app" class="hidden">
<!-- Settings-View — eigene "Unterseite" für Profil/Modell/Passwort -->
<section id="settings-view" class="card hidden settings-view">
<div class="settings-head">
<h2>Einstellungen für <span id="settings-username"></span></h2>
<button type="button" id="settings-back" class="btn-link">← zurück</button>
</div>
<div class="settings-section">
<h3>Standard-Profil</h3>
<p class="muted">Wird bei jedem neuen Upload vorausgewählt. Pro Upload überschreibbar.</p>
<select id="settings-profile" class="settings-input"></select>
<p id="settings-profile-msg" class="msg"></p>
</div>
<div class="settings-section" id="settings-model-section">
<h3>Standard-LLM-Modell</h3>
<p class="muted">Leer = der Mac-Worker entscheidet (sein .env-Default). Wechsel braucht keinen Worker-Restart — Ollama lädt das gewählte Modell beim ersten Job nach.</p>
<select id="settings-model" class="settings-input"></select>
<p id="settings-model-msg" class="msg"></p>
</div>
<div class="settings-section">
<h3>Passwort ändern</h3>
<form id="settings-pw-form" autocomplete="off">
<label>
Aktuelles Passwort
<input type="password" id="pw-current" autocomplete="current-password" required minlength="1" />
</label>
<label>
Neues Passwort <span class="muted">(min. 8 Zeichen)</span>
<input type="password" id="pw-new" autocomplete="new-password" required minlength="8" />
</label>
<label>
Neues Passwort wiederholen
<input type="password" id="pw-confirm" autocomplete="new-password" required minlength="8" />
</label>
<button type="submit" id="pw-save">Passwort speichern</button>
</form>
<p id="settings-pw-msg" class="msg"></p>
</div>
</section>
<!-- Haupt-View (Upload + Jobs + Admin) -->
<div id="main-view">
<section class="card upload-card">
<h2 class="desktop-only">Neue Aufnahme hochladen</h2>
<form id="upload-form">
@ -178,6 +213,7 @@
</table>
</div>
</section>
</div><!-- /main-view -->
</main>
<footer>

View File

@ -416,6 +416,55 @@ select {
.admin-toggle { cursor: default; }
.admin-toggle-icon { display: none; }
/* ── Settings-Subpage ─────────────────────────────────────────── */
.settings-view { max-width: 640px; }
.settings-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.settings-head h2 { margin: 0; }
.settings-section {
padding: 14px 0;
border-top: 1px solid var(--border);
}
.settings-section:first-of-type { border-top: 0; }
.settings-section h3 {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 600;
}
.settings-section p.muted { margin: 0 0 10px 0; font-size: 12.5px; }
.settings-input {
display: block;
width: 100%;
max-width: 380px;
padding: 7px 10px;
background: rgba(0,0,0,.25);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
font-size: 14px;
}
#settings-pw-form { display: flex; flex-direction: column; gap: 12px; max-width: 380px; }
#settings-pw-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
#settings-pw-form input {
padding: 8px 10px;
background: rgba(0,0,0,.25);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
font-size: 14px;
}
#settings-pw-form button {
align-self: flex-start;
margin-top: 4px;
padding: 8px 16px;
}
.settings-section .msg { margin-top: 8px; font-size: 12.5px; }
/* ── Mobile-Optimierung (echte Mobile-First-UI) ────────────────── */
@media (max-width: 700px) {
.desktop-only { display: none !important; }