Wizard: User erst beim Abschliessen, Reihenfolge Sprache/Design/Modus, Reset im Einzelmodus
Image bauen und in die Registry schieben / build (push) Successful in 6s

- Benutzer wird erst beim 'Einrichtung abschliessen' angelegt (POST
  /api/register im wizardFinish); bis dahin frei umentscheidbar, im Modus-
  Schritt Hinweis + Live-Validierung (Finish deaktiviert bis ausgefuellt)
- Reihenfolge: Sprache -> Darstellung (Theme/OLED/Akzent) -> Modus
  (Einzel/Mehrbenutzer); Onboarding bleibt Sprache -> Darstellung
- Sprach-Schritt: Repo-Link + Kurztext zum Starten (nutzt den Raum)
- Wizard-Modal: min-height statt fixer Hoehe -> Modus-Schritt mit Formular
  wird nicht mehr abgeschnitten
- Server-Reset auch im Einzelmodus moeglich (nur Bestaetigungstext,
  kein Passwort noetig); Reset-Sektion erscheint dann entsprechend
- Tests: 167 Checks gruen (39+18+52+32+26)
This commit is contained in:
AI-Agent committed 2026-10-02 19:57:59 +00:00
1 parent 655316278a
commit 135425c781
6 files changed
+125 -92

No files matched your search

+88 -78
View File
@@ -158,6 +158,8 @@ const LANG = {
prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom", prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom",
wizard_done: "Alles erledigt - die App ist bereit.", wizard_done: "Alles erledigt - die App ist bereit.",
wizard_user_created: "Benutzer '{u}' angelegt.", 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.", logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.",
app_load_fail: "App konnte nicht geladen werden: {m}", 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", prev_week: "Previous week", next_week: "Next week", week_of: "Week of",
wizard_done: "All done - the app is ready.", wizard_done: "All done - the app is ready.",
wizard_user_created: "User '{u}' created.", 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.", logged_in: "Logged in.", user_created_toast: "User created.",
app_load_fail: "Could not load the app: {m}", 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, 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) { function renderSetupWizard(mode) {
wizard.mode = mode; wizard.mode = mode;
wizard.step = 0; // Sprache zuerst wizard.step = 0; // Reihenfolge: Sprache -> Darstellung -> Modus
wizard.multi = null; wizard.multi = null;
wizard.lang = (app.settings && app.settings.language) || "de"; wizard.lang = (app.settings && app.settings.language) || "de";
wizard.theme = (app.settings && app.settings.theme) || "dark"; wizard.theme = (app.settings && app.settings.theme) || "dark";
wizard.oled = !!(app.settings && app.settings.oled); wizard.oled = !!(app.settings && app.settings.oled);
wizard.accent = (app.settings && app.settings.accent) || "white"; wizard.accent = (app.settings && app.settings.accent) || "white";
wizard.userCreated = false; wizard.regUser = null;
wizard.regPass = null;
document.body.classList.add("no-auth"); document.body.classList.add("no-auth");
$("#setup-gate").hidden = false; $("#setup-gate").hidden = false;
renderWizardStep(); renderWizardStep();
@@ -1698,11 +1704,11 @@ function previewWizardAppearance() {
function wizardStepValid() { function wizardStepValid() {
if (wizard.step === 0) return !!wizard.lang; if (wizard.step === 0) return !!wizard.lang;
if (wizard.mode === "setup" && wizard.step === 1) { if (wizard.step === 1) return !!wizard.theme && !!wizard.accent;
// Schritt 2 (nur Setup): Modus
if (wizard.multi === null) return false; if (wizard.multi === null) return false;
return wizard.multi === false || wizard.userCreated; if (wizard.multi === false) return true;
} return !!(wizard.regUser && wizard.regPass);
return !!wizard.theme && !!wizard.accent;
} }
function wizardMaxStep() { function wizardMaxStep() {
@@ -1731,7 +1737,7 @@ function renderWizardStep() {
const body = el("div", "wizard-body"); const body = el("div", "wizard-body");
if (wizard.step === 0) { if (wizard.step === 0) {
// Schritt 0: Sprache (zuerst) // Schritt 0: Sprache
body.appendChild(el("h3", "wizard-title", t("lang_step"))); body.appendChild(el("h3", "wizard-title", t("lang_step")));
if (wizard.mode === "setup") { if (wizard.mode === "setup") {
body.appendChild(el("p", "auth-sub", t("lang_server"))); body.appendChild(el("p", "auth-sub", t("lang_server")));
@@ -1748,48 +1754,14 @@ function renderWizardStep() {
row.appendChild(b); row.appendChild(b);
} }
body.appendChild(row); body.appendChild(row);
} else if (wizard.step === 1 && wizard.mode === "setup") { body.appendChild(el("p", "auth-sub", t("wizard_lang_hint")));
// Schritt 1: Einzel- oder Mehrbenutzermodus const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan");
body.appendChild(el("p", "auth-sub", t("welcome"))); repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan";
const grid = el("div", "setup-options"); repo.target = "_blank";
const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true); repo.rel = "noopener";
multi.addEventListener("click", () => { body.appendChild(repo);
wizard.multi = true; } else if (wizard.step === 1) {
renderWizardStep(); // Schritt 1: Darstellung (Theme + OLED + Akzent)
});
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("h3", "wizard-title", t("appearance"))); body.appendChild(el("h3", "wizard-title", t("appearance")));
body.appendChild(el("p", "auth-sub", t("appearance_desc"))); body.appendChild(el("p", "auth-sub", t("appearance_desc")));
body.appendChild(el("div", "picker-label", t("theme_label"))); body.appendChild(el("div", "picker-label", t("theme_label")));
@@ -1822,6 +1794,44 @@ function renderWizardStep() {
accentRow.appendChild(sw); accentRow.appendChild(sw);
} }
body.appendChild(accentRow); 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); box.appendChild(body);
@@ -1863,8 +1873,27 @@ function renderWizardStep() {
async function wizardFinish() { async function wizardFinish() {
const patch = { const patch = {
theme: wizard.theme, oled: wizard.oled, accent: wizard.accent, 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; if (app.me && app.me.username) patch.onboarded = true;
try { try {
app.settings = await api("/api/settings", jsonOpts("PUT", patch)); app.settings = await api("/api/settings", jsonOpts("PUT", patch));
@@ -1875,29 +1904,6 @@ async function wizardFinish() {
await afterAuth(); 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) Anmeldung (Auth-Gate)
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */
@@ -2113,7 +2119,7 @@ function renderSettingsView() {
if (app.me && (!app.me.username || app.me.is_owner)) { if (app.me && (!app.me.username || app.me.is_owner)) {
view.appendChild(renderUsersSection()); view.appendChild(renderUsersSection());
} }
if (app.me && app.me.is_owner) { if (app.me && (!app.me.username || app.me.is_owner)) {
view.appendChild(renderResetSection()); view.appendChild(renderResetSection());
} }
} }
@@ -2312,9 +2318,12 @@ function renderResetSection() {
const body = el("div", "data-body"); const body = el("div", "data-body");
body.appendChild(el("p", "", t("reset_server_desc"))); body.appendChild(el("p", "", t("reset_server_desc")));
const row = el("div", "form-row"); const row = el("div", "form-row");
const pw = el("input", "file-input"); let pw = null;
if (app.me.username) {
pw = el("input", "file-input");
pw.type = "password"; pw.type = "password";
pw.placeholder = t("current_pw"); pw.placeholder = t("current_pw");
}
const conf = el("input", "file-input"); const conf = el("input", "file-input");
conf.type = "text"; conf.type = "text";
conf.placeholder = t("reset_confirm_hint"); conf.placeholder = t("reset_confirm_hint");
@@ -2322,14 +2331,15 @@ function renderResetSection() {
btn.type = "button"; btn.type = "button";
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
try { try {
const body_pw = pw ? pw.value : "";
await api("/api/reset", jsonOpts("POST", { 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"); toast(t("reset_done"), "accent");
await afterAuth(); await afterAuth();
} catch (e) { flashError(e); } } catch (e) { flashError(e); }
}); });
row.appendChild(pw); if (pw) row.appendChild(pw);
row.appendChild(conf); row.appendChild(conf);
row.appendChild(btn); row.appendChild(btn);
body.appendChild(row); body.appendChild(row);
+4 -1
View File
@@ -1466,11 +1466,13 @@ class Handler(BaseHTTPRequestHandler):
uid = self._require_user() uid = self._require_user()
if uid is None: if uid is None:
return return
data = self._read_json()
if uid != OPEN_USER:
# Mehrbenutzermodus: nur der Owner, mit Passwort
if uid != 1: if uid != 1:
self._send(403, json.dumps({"error": "nur der erste Benutzer"}), self._send(403, json.dumps({"error": "nur der erste Benutzer"}),
"application/json; charset=utf-8") "application/json; charset=utf-8")
return return
data = self._read_json()
con = db() con = db()
row = con.execute("SELECT * FROM users WHERE id=?", row = con.execute("SELECT * FROM users WHERE id=?",
(uid,)).fetchone() (uid,)).fetchone()
@@ -1481,6 +1483,7 @@ class Handler(BaseHTTPRequestHandler):
{"error": "Aktuelles Passwort ist falsch"}), {"error": "Aktuelles Passwort ist falsch"}),
"application/json; charset=utf-8") "application/json; charset=utf-8")
return return
# Einzelmodus (offen): kein Passwort noetig, nur Bestaetigungstext
if _clean_str(data.get("confirm")) != "RESET": if _clean_str(data.get("confirm")) != "RESET":
self._send(400, json.dumps( self._send(400, json.dumps(
{"error": "Bestätigungstext ist falsch"}), {"error": "Bestätigungstext ist falsch"}),
+1 -1
View File
@@ -948,7 +948,7 @@ body.boot #setup-gate { visibility: hidden; }
} }
#setup-gate .auth-box { #setup-gate .auth-box {
height: 600px; min-height: 600px;
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
overflow: hidden; overflow: hidden;
} }
+10
View File
@@ -81,6 +81,8 @@ def main():
req("PATCH", f"/api/sessions/{sess['id']}", req("PATCH", f"/api/sessions/{sess['id']}",
{"meta": {"status": "done"}}) {"meta": {"status": "done"}})
# --- Registrierung (erster Benutzer) -------------------------------- # --- Registrierung (erster Benutzer) --------------------------------
st, reg = req("POST", "/api/register", st, reg = req("POST", "/api/register",
{"username": "alex", "password": "geheim"}) {"username": "alex", "password": "geheim"})
@@ -289,6 +291,14 @@ def main():
st, _ = req("GET", "/api/sessions") st, _ = req("GET", "/api/sessions")
check("Reset: Sessions ohne Anmeldung wieder sichtbar (offen)", st == 200, st) 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: finally:
proc.terminate() proc.terminate()
proc.wait(timeout=5) proc.wait(timeout=5)
+4 -4
View File
@@ -97,18 +97,18 @@ async function main() {
&& gate().querySelectorAll(".wp-step.is-done").length === 0); && gate().querySelectorAll(".wp-step.is-done").length === 0);
Array.from(gate().querySelectorAll(".wizard-nav .btn")) Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click(); .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", check("Setup: Fortschritt aktualisiert",
gate().querySelectorAll(".wp-step.is-done").length === 1 gate().querySelectorAll(".wp-step.is-done").length === 1
&& gate().querySelectorAll(".wp-step.is-current").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")); const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option"));
check("Setup: zwei gleichwertige Optionen", check("Setup: zwei gleichwertige Optionen",
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus")) setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
&& setupOptions().some(b => b.textContent.includes("Einzelmodus"))); && setupOptions().some(b => b.textContent.includes("Einzelmodus")));
setupOptions().find(b => b.textContent.includes("Einzelmodus")).click(); 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")) Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Einrichtung abschließen").click(); .find(b => b.textContent === "Einrichtung abschließen").click();
check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d => check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d =>
+18 -8
View File
@@ -85,6 +85,13 @@ check("Setup: Sprache kommt zuerst",
Array.from(sgate().querySelectorAll(".wizard-nav .btn")) Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Next").click(); .find(b => b.textContent === "Next").click();
await sleep(200); 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")); const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option"));
check("Setup: zwei gleichwertige Optionen", check("Setup: zwei gleichwertige Optionen",
setupOptions().some(b => b.textContent.includes("Multi-user mode")) setupOptions().some(b => b.textContent.includes("Multi-user mode"))
@@ -97,15 +104,18 @@ check("Setup: Sprache kommt zuerst",
check("Setup: Formular nach Mehrbenutzermodus-Klick", check("Setup: Formular nach Mehrbenutzermodus-Klick",
!!sgate().querySelector("input#setup-user") !!sgate().querySelector("input#setup-user")
&& !!sgate().querySelector("input#setup-pass")); && !!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(); 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")) Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Finish setup").click(); .find(b => b.textContent === "Finish setup").click();
await sleep(400); await sleep(400);