diff --git a/app.js b/app.js index 9c20155..4b44601 100644 --- a/app.js +++ b/app.js @@ -158,6 +158,8 @@ const LANG = { 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.", + wizard_lang_hint: "Bereit loszulegen? Anleitung, Demo-Daten und der Übungsplan stehen im Repo:", + wizard_user_later: "Der Benutzer wird beim Abschließen angelegt - du kannst dich im Wizard bis dahin umentscheiden.", logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.", app_load_fail: "App konnte nicht geladen werden: {m}", }, @@ -273,6 +275,8 @@ const LANG = { 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.", + wizard_lang_hint: "Ready to get started? The guide, demo data and the exercise plan are in the repo:", + wizard_user_later: "The user will be created when you finish - you can still change your mind until then.", logged_in: "Logged in.", user_created_toast: "User created.", app_load_fail: "Could not load the app: {m}", }, @@ -1649,17 +1653,19 @@ function switchView(name) { ------------------------------------------------------------------------- */ const wizard = { mode: "setup", step: 0, multi: null, lang: null, - theme: null, oled: false, accent: null, userCreated: false }; + theme: null, oled: false, accent: null, + regUser: null, regPass: null }; function renderSetupWizard(mode) { wizard.mode = mode; - wizard.step = 0; // Sprache zuerst + wizard.step = 0; // Reihenfolge: Sprache -> Darstellung -> Modus wizard.multi = null; wizard.lang = (app.settings && app.settings.language) || "de"; wizard.theme = (app.settings && app.settings.theme) || "dark"; wizard.oled = !!(app.settings && app.settings.oled); wizard.accent = (app.settings && app.settings.accent) || "white"; - wizard.userCreated = false; + wizard.regUser = null; + wizard.regPass = null; document.body.classList.add("no-auth"); $("#setup-gate").hidden = false; renderWizardStep(); @@ -1698,11 +1704,11 @@ function previewWizardAppearance() { function wizardStepValid() { if (wizard.step === 0) return !!wizard.lang; - if (wizard.mode === "setup" && wizard.step === 1) { - if (wizard.multi === null) return false; - return wizard.multi === false || wizard.userCreated; - } - return !!wizard.theme && !!wizard.accent; + if (wizard.step === 1) return !!wizard.theme && !!wizard.accent; + // Schritt 2 (nur Setup): Modus + if (wizard.multi === null) return false; + if (wizard.multi === false) return true; + return !!(wizard.regUser && wizard.regPass); } function wizardMaxStep() { @@ -1731,7 +1737,7 @@ function renderWizardStep() { const body = el("div", "wizard-body"); if (wizard.step === 0) { - // Schritt 0: Sprache (zuerst) + // Schritt 0: Sprache body.appendChild(el("h3", "wizard-title", t("lang_step"))); if (wizard.mode === "setup") { body.appendChild(el("p", "auth-sub", t("lang_server"))); @@ -1748,48 +1754,14 @@ function renderWizardStep() { row.appendChild(b); } body.appendChild(row); - } else if (wizard.step === 1 && wizard.mode === "setup") { - // Schritt 1: Einzel- oder Mehrbenutzermodus - 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", () => { - wizard.multi = true; - 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); - 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 })); - body.appendChild(ok); - } else { - const form = el("form", "auth-form"); - form.appendChild(authField(t("username"), "setup-user")); - form.appendChild(authField(t("password"), "setup-pass")); - const err = el("div", "auth-error", ""); - err.hidden = true; - const submit = el("button", "btn btn-primary btn-block", t("first_user")); - submit.type = "submit"; - form.appendChild(submit); - form.appendChild(err); - form.addEventListener("submit", ev => { - ev.preventDefault(); - wizardRegister($("#setup-user").value, $("#setup-pass").value, err); - }); - body.appendChild(form); - } - } - } else { - // Letzter Schritt: Darstellung (Theme + OLED + Akzent) + body.appendChild(el("p", "auth-sub", t("wizard_lang_hint"))); + const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan"); + repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan"; + repo.target = "_blank"; + repo.rel = "noopener"; + body.appendChild(repo); + } else if (wizard.step === 1) { + // Schritt 1: Darstellung (Theme + OLED + Akzent) 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"))); @@ -1822,6 +1794,44 @@ function renderWizardStep() { accentRow.appendChild(sw); } body.appendChild(accentRow); + } else { + // Schritt 2 (nur Setup): Einzel- oder Mehrbenutzermodus + 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", () => { + wizard.multi = true; + renderWizardStep(); + }); + grid.appendChild(multi); + const solo = setupOption(t("solo_mode"), t("solo_desc"), wizard.multi === false); + solo.addEventListener("click", () => { + wizard.multi = false; + renderWizardStep(); + }); + grid.appendChild(solo); + body.appendChild(grid); + if (wizard.multi === true) { + const form = el("form", "auth-form"); + form.appendChild(authField(t("username"), "setup-user")); + form.appendChild(authField(t("password"), "setup-pass")); + form.appendChild(el("div", "auth-register", t("wizard_user_later"))); + form.addEventListener("submit", ev => ev.preventDefault()); + body.appendChild(form); + // Felder in wizard-Zustand schreiben; Finish live aktivieren + const uInput = form.querySelector("#setup-user"); + const pInput = form.querySelector("#setup-pass"); + uInput.value = wizard.regUser || ""; + pInput.value = wizard.regPass || ""; + const sync = () => { + wizard.regUser = uInput.value.trim(); + wizard.regPass = pInput.value.trim(); + const fin = box.querySelector(".wizard-nav .btn-primary"); + if (fin) fin.disabled = !wizardStepValid(); + }; + uInput.addEventListener("input", sync); + pInput.addEventListener("input", sync); + } } box.appendChild(body); @@ -1863,8 +1873,27 @@ function renderWizardStep() { async function wizardFinish() { const patch = { theme: wizard.theme, oled: wizard.oled, accent: wizard.accent, - language: wizard.lang, setupDone: true, + language: wizard.lang, }; + if (wizard.mode === "setup") { + patch.setupDone = true; + if (wizard.multi) { + try { + const r = await api("/api/register", jsonOpts("POST", { + username: wizard.regUser, password: wizard.regPass, + })); + localStorage.setItem("tp_token", r.token); + app.me = { username: r.username, is_owner: true }; + } catch (e) { + if (e.status === 403) { + setTimeout(() => afterAuth(), 600); + } else { + flashError(e); + } + return; + } + } + } if (app.me && app.me.username) patch.onboarded = true; try { app.settings = await api("/api/settings", jsonOpts("PUT", patch)); @@ -1875,29 +1904,6 @@ async function wizardFinish() { await afterAuth(); } -async function wizardRegister(username, password, errEl) { - try { - const r = await api("/api/register", jsonOpts("POST", { - username, password, - })); - localStorage.setItem("tp_token", r.token); - app.me = { username: r.username, is_owner: true }; - wizard.userCreated = true; - renderWizardStep(); - } catch (e) { - if (e.status === 403) { - // Ein Benutzer existiert inzwischen (z.B. zweiter Tab): neu laden - if (errEl) errEl.textContent = eMsg(e); - setTimeout(() => afterAuth(), 600); - } else if (errEl) { - errEl.hidden = false; - errEl.textContent = eMsg(e); - } else { - flashError(e); - } - } -} - /* --------------------------------------------------------------------------- Anmeldung (Auth-Gate) ------------------------------------------------------------------------- */ @@ -2113,7 +2119,7 @@ function renderSettingsView() { if (app.me && (!app.me.username || app.me.is_owner)) { view.appendChild(renderUsersSection()); } - if (app.me && app.me.is_owner) { + if (app.me && (!app.me.username || app.me.is_owner)) { view.appendChild(renderResetSection()); } } @@ -2312,9 +2318,12 @@ function renderResetSection() { 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"); + let pw = null; + if (app.me.username) { + 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"); @@ -2322,14 +2331,15 @@ function renderResetSection() { btn.type = "button"; btn.addEventListener("click", async () => { try { + const body_pw = pw ? pw.value : ""; await api("/api/reset", jsonOpts("POST", { - password: pw.value, confirm: conf.value.trim(), + password: body_pw, confirm: conf.value.trim(), })); toast(t("reset_done"), "accent"); await afterAuth(); } catch (e) { flashError(e); } }); - row.appendChild(pw); + if (pw) row.appendChild(pw); row.appendChild(conf); row.appendChild(btn); body.appendChild(row); diff --git a/server.py b/server.py index 215eb46..db1f2c9 100644 --- a/server.py +++ b/server.py @@ -1466,21 +1466,24 @@ class Handler(BaseHTTPRequestHandler): 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 uid != OPEN_USER: + # Mehrbenutzermodus: nur der Owner, mit Passwort + if uid != 1: + self._send(403, json.dumps({"error": "nur der erste Benutzer"}), + "application/json; charset=utf-8") + return + 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 + # Einzelmodus (offen): kein Passwort noetig, nur Bestaetigungstext if _clean_str(data.get("confirm")) != "RESET": self._send(400, json.dumps( {"error": "Bestätigungstext ist falsch"}), diff --git a/styles.css b/styles.css index f04ba83..dde40dd 100644 --- a/styles.css +++ b/styles.css @@ -948,7 +948,7 @@ body.boot #setup-gate { visibility: hidden; } } #setup-gate .auth-box { - height: 600px; + min-height: 600px; max-height: calc(100vh - 48px); overflow: hidden; } diff --git a/tests/test_auth.py b/tests/test_auth.py index 7cae13a..70f0e6e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -81,6 +81,8 @@ def main(): req("PATCH", f"/api/sessions/{sess['id']}", {"meta": {"status": "done"}}) + + # --- Registrierung (erster Benutzer) -------------------------------- st, reg = req("POST", "/api/register", {"username": "alex", "password": "geheim"}) @@ -289,6 +291,14 @@ def main(): st, _ = req("GET", "/api/sessions") check("Reset: Sessions ohne Anmeldung wieder sichtbar (offen)", st == 200, st) + # Reset im Einzelmodus (nach Owner-Reset ist der Server offen) + st, _ = req("POST", "/api/reset", {"confirm": "RESET"}) + check("Reset im Einzelmodus: ohne Passwort -> 200", st == 200, st) + st, _ = req("POST", "/api/reset", {"confirm": "NEIN"}) + check("Reset im Einzelmodus: falscher Text -> 400", st == 400, st) + st, meta = req("GET", "/api/meta") + check("nach Einzelmodus-Reset: weiterhin offen", meta["users"] == 0, meta) + finally: proc.terminate() proc.wait(timeout=5) diff --git a/tests/test_ui.js b/tests/test_ui.js index 21d1b48..6e6ca60 100644 --- a/tests/test_ui.js +++ b/tests/test_ui.js @@ -97,18 +97,18 @@ async function main() { && gate().querySelectorAll(".wp-step.is-done").length === 0); Array.from(gate().querySelectorAll(".wizard-nav .btn")) .find(b => b.textContent === "Weiter").click(); - await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2); + await waitFor(dom, d => gate().querySelector(".accent-row")); check("Setup: Fortschritt aktualisiert", gate().querySelectorAll(".wp-step.is-done").length === 1 && gate().querySelectorAll(".wp-step.is-current").length === 1); + Array.from(gate().querySelectorAll(".wizard-nav .btn")) + .find(b => b.textContent === "Weiter").click(); + await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2); const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option")); check("Setup: zwei gleichwertige Optionen", setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus")) && setupOptions().some(b => b.textContent.includes("Einzelmodus"))); setupOptions().find(b => b.textContent.includes("Einzelmodus")).click(); - Array.from(gate().querySelectorAll(".wizard-nav .btn")) - .find(b => b.textContent === "Weiter").click(); - await waitFor(dom, d => gate().querySelector(".accent-row")); Array.from(gate().querySelectorAll(".wizard-nav .btn")) .find(b => b.textContent === "Einrichtung abschließen").click(); check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d => diff --git a/tests/test_ui_auth.js b/tests/test_ui_auth.js index 57221f8..f18f7bb 100644 --- a/tests/test_ui_auth.js +++ b/tests/test_ui_auth.js @@ -85,6 +85,13 @@ check("Setup: Sprache kommt zuerst", Array.from(sgate().querySelectorAll(".wizard-nav .btn")) .find(b => b.textContent === "Next").click(); await sleep(200); + check("Setup: Darstellung kommt als zweites", + sgate().querySelector(".wizard-title").textContent === "Appearance" + && !!sgate().querySelector(".accent-row") + && !!sgate().querySelector(".toggle-row")); + Array.from(sgate().querySelectorAll(".wizard-nav .btn")) + .find(b => b.textContent === "Next").click(); + await sleep(200); const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option")); check("Setup: zwei gleichwertige Optionen", setupOptions().some(b => b.textContent.includes("Multi-user mode")) @@ -97,15 +104,18 @@ check("Setup: Sprache kommt zuerst", check("Setup: Formular nach Mehrbenutzermodus-Klick", !!sgate().querySelector("input#setup-user") && !!sgate().querySelector("input#setup-pass")); - // Einzelmodus waehlen -> Weiter -> Darstellung -> abschliessen + // User wird erst beim Abschliessen erstellt (nicht beim Tippen) + sgate().querySelector("#setup-user").value = "alex"; + sgate().querySelector("#setup-pass").value = "geheim"; + sgate().querySelector("#setup-user") + .dispatchEvent(new winSetup.window.Event("input", { bubbles: true })); + sgate().querySelector("#setup-pass") + .dispatchEvent(new winSetup.window.Event("input", { bubbles: true })); + await sleep(150); + check("Setup: User wird erst beim Abschliessen erstellt", + (await api("GET", "/api/meta")).body.users === 0); + // Einzelmodus waehlen -> abschliessen (User wird NICHT erstellt) setupOptions().find(b => b.textContent.includes("Single-user mode")).click(); - Array.from(sgate().querySelectorAll(".wizard-nav .btn")) - .find(b => b.textContent === "Next").click(); - await sleep(200); - check("Setup: Darstellung-Schritt (Theme + Akzent)", - sgate().querySelector(".wizard-title").textContent === "Appearance" - && !!sgate().querySelector(".accent-row") - && !!sgate().querySelector(".toggle-row")); Array.from(sgate().querySelectorAll(".wizard-nav .btn")) .find(b => b.textContent === "Finish setup").click(); await sleep(400);