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:
parent
f03d46afb5
commit
7679efa7e4
@ -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)."""
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user