From 655316278a039e1dc6fc9d1048529f403de7e8b7 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Fri, 2 Oct 2026 19:42:46 +0000 Subject: [PATCH] Wizard-Hoehe fix, Logout in Konto, Server-Reset, responsive Tabs, Set-Zeilen, Brand-Date - Wizard: feste Hoehe (600px, intern scrollbar), Schritt-Inhalt zentriert (.wizard-body) -> Schritte gleich gross, auch beim User anlegen - Benutzer-Anlage: Bestaetigung 'Benutzer x angelegt.' (ohne 'Weiter mit'), Solo-Option nach User-Anlage deaktiviert (kein irrefuehrendes Umschalten) - User-Chip + Abmelden aus der Topbar entfernt, 'Abmelden' in Konto-Sektion - Topbar-Datum: 'Fr 02.10.2026' + aktive Session ('Push aktiv') statt redundantem 'Today' - Tabs: flex-wrap statt Scrollen, auf Mobil volle Breite + kompakter - Server-Reset (Owner): Passwort + 'RESET'-Eingabe, loescht alle Daten - Benutzerliste: Querbalken vor dem Anlegen-Formular - Tests: 163 Checks gruen (39+18+49+32+25) --- app.js | 136 ++++++++++++++++++++++++++++++------------ index.html | 2 - server.py | 39 ++++++++++++ styles.css | 25 +++++++- tests/test_auth.py | 19 ++++++ tests/test_ui_auth.js | 14 ++--- 6 files changed, 183 insertions(+), 52 deletions(-) diff --git a/app.js b/app.js index f6c02e0..9c20155 100644 --- a/app.js +++ b/app.js @@ -114,6 +114,11 @@ const LANG = { setting_saved: "Einstellung gespeichert.", account: "Konto", account_desc: "Angemeldet als '{u}'. Kennwort ändern:", change_pw: "Kennwort ändern", current_pw: "Aktuelles Kennwort", + logout_label: "Abmelden", session_active: "aktiv", + reset_server: "Server zurücksetzen", + reset_server_desc: "Löscht alle Daten und Benutzer dieser Instanz. Die App startet danach wie neu (Einrichtungs-Wizard). Gib dein Kennwort ein und tippe RESET zum Bestätigen.", + reset_confirm_hint: "Zum Bestätigen RESET eintippen", + reset_server_go: "Zurücksetzen", reset_done: "Server zurückgesetzt.", new_pw: "Neues Kennwort", pw_changed: "Kennwort geändert.", users: "Benutzer", setup_users: "Benutzer einrichten", users_solo_desc: "Du bist im Einzelmodus (ohne Anmeldung). Lege den ersten Benutzer an, um Login und mehrere Benutzer zu aktivieren.", @@ -152,7 +157,7 @@ const LANG = { reorder_hint: "Übungen per Ziehen sortieren", prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom", wizard_done: "Alles erledigt - die App ist bereit.", - wizard_user_created: "Benutzer '{u}' angelegt. Weiter mit 'Weiter'.", + wizard_user_created: "Benutzer '{u}' angelegt.", logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.", app_load_fail: "App konnte nicht geladen werden: {m}", }, @@ -224,6 +229,11 @@ const LANG = { setting_saved: "Setting saved.", account: "Account", account_desc: "Logged in as '{u}'. Change password:", change_pw: "Change password", current_pw: "Current password", + logout_label: "Log out", session_active: "active", + reset_server: "Reset server", + reset_server_desc: "Deletes all data and users of this instance. The app starts fresh afterwards (setup wizard). Enter your password and type RESET to confirm.", + reset_confirm_hint: "Type RESET to confirm", + reset_server_go: "Reset", reset_done: "Server reset.", new_pw: "New password", pw_changed: "Password changed.", users: "Users", setup_users: "Set up users", users_solo_desc: "You are in single-user mode (no login). Create the first user to enable login and multiple users.", @@ -262,7 +272,7 @@ const LANG = { reorder_hint: "Drag to reorder exercises", prev_week: "Previous week", next_week: "Next week", week_of: "Week of", wizard_done: "All done - the app is ready.", - wizard_user_created: "User '{u}' created. Continue with 'Next'.", + wizard_user_created: "User '{u}' created.", logged_in: "Logged in.", user_created_toast: "User created.", app_load_fail: "Could not load the app: {m}", }, @@ -1718,12 +1728,13 @@ function renderWizardStep() { head.appendChild(el("h2", "auth-title", BRAND)); head.appendChild(wizardProgress()); box.appendChild(head); + const body = el("div", "wizard-body"); if (wizard.step === 0) { // Schritt 0: Sprache (zuerst) - box.appendChild(el("h3", "wizard-title", t("lang_step"))); + body.appendChild(el("h3", "wizard-title", t("lang_step"))); if (wizard.mode === "setup") { - box.appendChild(el("p", "auth-sub", t("lang_server"))); + body.appendChild(el("p", "auth-sub", t("lang_server"))); } const row = el("div", "day-picker"); for (const [code, label] of [["de", t("german")], ["en", t("english")]]) { @@ -1736,10 +1747,10 @@ function renderWizardStep() { }); row.appendChild(b); } - box.appendChild(row); + body.appendChild(row); } else if (wizard.step === 1 && wizard.mode === "setup") { // Schritt 1: Einzel- oder Mehrbenutzermodus - box.appendChild(el("p", "auth-sub", t("welcome"))); + body.appendChild(el("p", "auth-sub", t("welcome"))); const grid = el("div", "setup-options"); const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true); multi.addEventListener("click", () => { @@ -1748,17 +1759,18 @@ function renderWizardStep() { }); grid.appendChild(multi); const solo = setupOption(t("solo_mode"), t("solo_desc"), wizard.multi === false); + if (wizard.userCreated) solo.disabled = true; solo.addEventListener("click", () => { wizard.multi = false; renderWizardStep(); }); grid.appendChild(solo); - box.appendChild(grid); + body.appendChild(grid); if (wizard.multi === true) { if (wizard.userCreated && app.me && app.me.username) { const ok = el("div", "auth-register", t("wizard_user_created", { u: app.me.username })); - box.appendChild(ok); + body.appendChild(ok); } else { const form = el("form", "auth-form"); form.appendChild(authField(t("username"), "setup-user")); @@ -1773,14 +1785,14 @@ function renderWizardStep() { ev.preventDefault(); wizardRegister($("#setup-user").value, $("#setup-pass").value, err); }); - box.appendChild(form); + body.appendChild(form); } } } else { // Letzter Schritt: Darstellung (Theme + OLED + Akzent) - box.appendChild(el("h3", "wizard-title", t("appearance"))); - box.appendChild(el("p", "auth-sub", t("appearance_desc"))); - box.appendChild(el("div", "picker-label", t("theme_label"))); + body.appendChild(el("h3", "wizard-title", t("appearance"))); + body.appendChild(el("p", "auth-sub", t("appearance_desc"))); + body.appendChild(el("div", "picker-label", t("theme_label"))); const themeRow = el("div", "day-picker"); for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) { const b = el("button", "day-btn" + (wizard.theme === key ? " is-active" : ""), label); @@ -1792,10 +1804,10 @@ function renderWizardStep() { }); themeRow.appendChild(b); } - box.appendChild(themeRow); - box.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled, + body.appendChild(themeRow); + body.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled, previewWizardAppearance)); - box.appendChild(el("div", "picker-label", t("accent_label"))); + body.appendChild(el("div", "picker-label", t("accent_label"))); const accentRow = el("div", "accent-row"); for (const [key, color] of Object.entries(ACCENTS)) { const sw = el("button", "accent-swatch" + (wizard.accent === key ? " is-active" : ""), ""); @@ -1809,9 +1821,11 @@ function renderWizardStep() { }); accentRow.appendChild(sw); } - box.appendChild(accentRow); + body.appendChild(accentRow); } + box.appendChild(body); + // Fusszeile: Zurueck links, Weiter/Abschliessen rechts const nav = el("div", "wizard-nav"); if (wizard.step > 0) { @@ -1943,18 +1957,15 @@ async function doAuth(mode, username, password, errEl) { } } -function setupLogout() { - const btn = $("#logout-btn"); - btn.addEventListener("click", async () => { - try { - await api("/api/logout", { method: "POST" }); - } catch (_) {} - localStorage.removeItem("tp_token"); - app.me = null; - app.models = {}; - $("#view-today").innerHTML = ""; - $("#today-label").textContent = ""; - app.meta = await api("/api/meta"); +function logout() { + api("/api/logout", { method: "POST" }).catch(() => {}); + localStorage.removeItem("tp_token"); + app.me = null; + app.models = {}; + $("#view-today").innerHTML = ""; + $("#today-label").textContent = ""; + api("/api/meta").then(meta => { + app.meta = meta; app.settings = { language: app.meta ? app.meta.language : "de" }; document.body.classList.add("no-auth"); $("#setup-gate").hidden = true; @@ -1963,14 +1974,11 @@ function setupLogout() { }); } -function setUserChip() { - const chip = $("#user-chip"); - const btn = $("#logout-btn"); - if (app.me && app.me.username) { - chip.hidden = false; - chip.textContent = app.me.username; - btn.hidden = false; - } +function logoutButton() { + const b = el("button", "btn btn-danger-ghost", t("logout_label")); + b.type = "button"; + b.addEventListener("click", () => logout()); + return b; } /* --------------------------------------------------------------------------- @@ -2105,6 +2113,9 @@ function renderSettingsView() { if (app.me && (!app.me.username || app.me.is_owner)) { view.appendChild(renderUsersSection()); } + if (app.me && app.me.is_owner) { + view.appendChild(renderResetSection()); + } } function settingsSelect(key, label, options) { @@ -2149,6 +2160,9 @@ function renderAccountSection() { row.appendChild(neu); row.appendChild(btn); body.appendChild(row); + const logoutRow = el("div", "form-row"); + logoutRow.appendChild(logoutButton()); + body.appendChild(logoutRow); sec.appendChild(body); return sec; } @@ -2263,6 +2277,7 @@ function renderOwnerUsersSection() { } }; reload(); + body.appendChild(el("div", "divider")); const row = el("div", "form-row"); const name = el("input", "file-input"); name.type = "text"; @@ -2291,6 +2306,37 @@ function renderOwnerUsersSection() { return sec; } +function renderResetSection() { + const sec = el("section", "data-section"); + sec.appendChild(el("h3", "", t("reset_server"))); + const body = el("div", "data-body"); + body.appendChild(el("p", "", t("reset_server_desc"))); + const row = el("div", "form-row"); + const pw = el("input", "file-input"); + pw.type = "password"; + pw.placeholder = t("current_pw"); + const conf = el("input", "file-input"); + conf.type = "text"; + conf.placeholder = t("reset_confirm_hint"); + const btn = el("button", "btn btn-danger-ghost", t("reset_server_go")); + btn.type = "button"; + btn.addEventListener("click", async () => { + try { + await api("/api/reset", jsonOpts("POST", { + password: pw.value, confirm: conf.value.trim(), + })); + toast(t("reset_done"), "accent"); + await afterAuth(); + } catch (e) { flashError(e); } + }); + row.appendChild(pw); + row.appendChild(conf); + row.appendChild(btn); + body.appendChild(row); + sec.appendChild(body); + return sec; +} + function toggleRow(key, label, desc) { const row = el("label", "toggle-row"); const cb = el("input", "toggle-input"); @@ -2314,7 +2360,6 @@ function setupUi() { tab.addEventListener("click", () => switchView(tab.dataset.view)); } setupOverlay(); - setupLogout(); setupThemeWatch(); } @@ -2334,6 +2379,20 @@ async function init() { await loadAppData(); } +function updateTodayLabel() { + const label = $("#today-label"); + if (!label) return; + const active = Object.values(app.models) + .find(m => m.id && m.date === app.meta.today); + if (active) { + const a = cfgActivity(active.activity); + const day = active.day ? " · " + active.day : ""; + label.textContent = fmtDateLong(app.meta.today) + day + " " + t("session_active"); + } else { + label.textContent = fmtDateLong(app.meta.today); + } +} + async function loadAppData() { app.cfg = await api("/api/config"); app.settings = await api("/api/settings"); @@ -2342,8 +2401,7 @@ async function loadAppData() { const key = "tab_" + tab.dataset.view; if (LANG[lang()][key]) tab.textContent = t(key); } - $("#today-label").textContent = t("today_header") + fmtDateLong(app.meta.today); - setUserChip(); + updateTodayLabel(); await loadToday(); const startTab = app.settings.startTab || "today"; if (startTab !== "today") switchView(startTab); diff --git a/index.html b/index.html index 40bd273..7d258dc 100644 --- a/index.html +++ b/index.html @@ -19,8 +19,6 @@ - -
diff --git a/server.py b/server.py index be6b4ec..215eb46 100644 --- a/server.py +++ b/server.py @@ -770,6 +770,16 @@ def set_user_password(uid, new): return True +def reset_server(): + """Kompletter Reset: alle Sessions, Settings, Tokens und Benutzer loeschen.""" + con = db() + for t in ("entries", "sessions", "settings", "tokens", "users"): + con.execute(f"DELETE FROM {t}") + con.execute("DELETE FROM sqlite_sequence") + con.commit() + con.close() + + # --------------------------------------------------------------------------- # Auto-Reset: letztes Workout einer Kategorie laden # --------------------------------------------------------------------------- @@ -1415,6 +1425,8 @@ class Handler(BaseHTTPRequestHandler): self._send(400, json.dumps( {"error": "Aktuelles Passwort ist falsch"}), "application/json; charset=utf-8") + elif path in ("/api/reset", "/api/reset/"): + self.do_reset() else: uid = self._require_user() if uid is None: @@ -1450,6 +1462,33 @@ class Handler(BaseHTTPRequestHandler): self._send(500, json.dumps({"error": str(exc)}), "application/json; charset=utf-8") + def do_reset(self): + uid = self._require_user() + if uid is None: + return + if uid != 1: + self._send(403, json.dumps({"error": "nur der erste Benutzer"}), + "application/json; charset=utf-8") + return + data = self._read_json() + con = db() + row = con.execute("SELECT * FROM users WHERE id=?", + (uid,)).fetchone() + con.close() + if not row or not _verify_pw(data.get("password", ""), + row["salt"], row["password_hash"]): + self._send(400, json.dumps( + {"error": "Aktuelles Passwort ist falsch"}), + "application/json; charset=utf-8") + return + if _clean_str(data.get("confirm")) != "RESET": + self._send(400, json.dumps( + {"error": "Bestätigungstext ist falsch"}), + "application/json; charset=utf-8") + return + reset_server() + self._send_json(200, {"ok": True}) + def do_register(self): if user_count() > 0: self._send(403, json.dumps( diff --git a/styles.css b/styles.css index 9aa64ab..f04ba83 100644 --- a/styles.css +++ b/styles.css @@ -139,10 +139,10 @@ html, body { .tabs { display: flex; + flex-wrap: wrap; gap: 0; margin-left: auto; border: 2px solid var(--border); - overflow-x: auto; max-width: 100%; } @@ -153,7 +153,9 @@ html, body { @media (max-width: 700px) { .topbar { padding: 10px 12px; } - .tab { padding: 6px 12px; } + .tabs { margin-left: 0; width: 100%; } + .tab { padding: 6px 10px; font-size: 13px; } + .brand-name { font-size: 15px; } } .tab { @@ -945,7 +947,19 @@ body.boot #setup-gate { visibility: hidden; } text-align: left; } -#setup-gate .auth-box { min-height: 560px; } +#setup-gate .auth-box { + height: 600px; + max-height: calc(100vh - 48px); + overflow: hidden; +} + +.wizard-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + overflow-y: auto; +} .auth-head { display: flex; @@ -1099,6 +1113,11 @@ button:disabled { cursor: not-allowed; } +.divider { + border-top: 2px solid var(--border); + margin: 14px 0; +} + .settings-select-row { display: flex; flex-direction: column; diff --git a/tests/test_auth.py b/tests/test_auth.py index a131bed..7cae13a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -270,6 +270,25 @@ def main(): st, done_a = req("GET", "/api/sessions?status=done", token=token_a) check("A-Daten nach Loeschung von B intakt", len(done_a) == 1, len(done_a)) + # --- Server-Reset (doppelt gesichert) ----------------------------------- + st, _ = req("POST", "/api/reset", + {"password": "falsch", "confirm": "RESET"}, token=token_a) + check("Reset: falsches Passwort -> 400", st == 400, st) + st, _ = req("POST", "/api/reset", + {"password": "neu123", "confirm": "NEIN"}, token=token_a) + check("Reset: falscher Bestaetigungstext -> 400", st == 400, st) + st, _ = req("POST", "/api/reset", + {"password": "neu123", "confirm": "RESET"}, token=token_a) + check("Reset: korrekt -> 200", st == 200, st) + st, meta = req("GET", "/api/meta") + check("Reset: offener Modus (kein Benutzer mehr)", + meta["users"] == 0 and meta["auth_required"] is False, meta) + st, me = req("GET", "/api/me", token=token_a) + check("Reset: im offenen Modus kein Benutzername", + st == 200 and me.get("username") is None, me) + st, _ = req("GET", "/api/sessions") + check("Reset: Sessions ohne Anmeldung wieder sichtbar (offen)", st == 200, st) + finally: proc.terminate() proc.wait(timeout=5) diff --git a/tests/test_ui_auth.js b/tests/test_ui_auth.js index 75080a5..57221f8 100644 --- a/tests/test_ui_auth.js +++ b/tests/test_ui_auth.js @@ -152,7 +152,7 @@ check("Setup: Sprache kommt zuerst", break; } if (winNoToken.document.querySelectorAll(".act-card").length === 3 - && winNoToken.document.querySelector("#user-chip").textContent === "alex") { + && winNoToken.document.querySelector("#logout-btn") === null) { loggedIn = true; break; } @@ -174,10 +174,7 @@ check("Setup: Sprache kommt zuerst", } check("Login: Onboarding abgeschlossen, Heute-Ansicht sichtbar", ogate() && ogate().hidden - && winNoToken.document.querySelectorAll(".act-card").length === 3 - && winNoToken.document.querySelector("#user-chip").textContent === "alex"); - check("Login: Abmelden-Knopf sichtbar", - !winNoToken.document.querySelector("#logout-btn").hidden); + && winNoToken.document.querySelectorAll(".act-card").length === 3); check("Login: kein Auth-Gate mehr", winNoToken.document.querySelector("#auth-gate").hidden); @@ -186,8 +183,7 @@ check("Setup: Sprache kommt zuerst", await sleep(1200); let tokOk = false; for (let i = 0; i < 50; i++) { - if (winTok.document.querySelectorAll(".act-card").length === 3 - && winTok.document.querySelector("#user-chip").textContent === "alex") { + if (winTok.document.querySelectorAll(".act-card").length === 3) { tokOk = true; break; } @@ -210,7 +206,9 @@ check("Setup: Sprache kommt zuerst", && Array.from(winTok.document.querySelectorAll(".data-section")) .some(s => s.querySelector("h3").textContent === "Konto" && !!s.querySelector('input[placeholder="Aktuelles Kennwort"]') - && !!s.querySelector('input[placeholder="Neues Kennwort"]'))); + && !!s.querySelector('input[placeholder="Neues Kennwort"]') + && Array.from(s.querySelectorAll(".btn")) + .some(b => b.textContent === "Abmelden"))); check("Einstellungen: nur ein Benutzer -> Hinweis statt Liste", await (async () => { for (let i = 0; i < 40; i++) { if (Array.from(winTok.document.querySelectorAll(".user-row"))