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",
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.step === 1) return !!wizard.theme && !!wizard.accent;
// Schritt 2 (nur Setup): Modus
if (wizard.multi === null) return false;
return wizard.multi === false || wizard.userCreated;
}
return !!wizard.theme && !!wizard.accent;
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");
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);
+4 -1
View File
@@ -1466,11 +1466,13 @@ class Handler(BaseHTTPRequestHandler):
uid = self._require_user()
if uid is None:
return
data = self._read_json()
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
data = self._read_json()
con = db()
row = con.execute("SELECT * FROM users WHERE id=?",
(uid,)).fetchone()
@@ -1481,6 +1483,7 @@ class Handler(BaseHTTPRequestHandler):
{"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"}),
+1 -1
View File
@@ -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;
}
+10
View File
@@ -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)
+4 -4
View File
@@ -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 =>
+18 -8
View File
@@ -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);