Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

233
web/html/js/views/auth.js Normal file
View File

@@ -0,0 +1,233 @@
// Anmeldung, Registrierung, Passwort zurücksetzen und Startpasswortwechsel.
"use strict";
import { ApiError, get, post } from "../api.js";
import { clear, el, mount } from "../dom.js";
import { set, state } from "../store.js";
/** Baut ein Formular ohne <form>-Element: dessen Standardverhalten
* (Seite neu laden) stört hier nur. Enter löst trotzdem aus. */
function form({ title, lead, leadWarn, fields, submitLabel, onSubmit, links = [] }) {
const inputs = {};
const error = el("p.error", { hidden: true });
const notice = el("p.notice", { hidden: true });
const button = el("button.primary", { type: "button" }, submitLabel);
const showError = (message) => {
error.textContent = message || "";
error.hidden = !message;
};
const showNotice = (message) => {
notice.textContent = message || "";
notice.hidden = !message;
};
const run = async () => {
showError("");
showNotice("");
button.disabled = true;
try {
const values = Object.fromEntries(
Object.entries(inputs).map(([k, i]) => [k, i.value])
);
await onSubmit(values, { showNotice, showError });
} catch (err) {
showError(err.message);
} finally {
button.disabled = false;
}
};
button.addEventListener("click", run);
const rows = fields.map((f) => {
const input = el("input", {
id: `f-${f.name}`,
type: f.type || "text",
autocomplete: f.autocomplete || "off",
maxLength: f.maxLength || 256,
onkeydown: (ev) => {
if (ev.key === "Enter") run();
},
});
inputs[f.name] = input;
return [
el("label", { htmlFor: `f-${f.name}` },
f.label,
f.hint ? el("span.hint", {}, ` ${f.hint}`) : null),
input,
];
});
return el("section.card", {},
el("h1", {}, title),
lead ? el(leadWarn ? "p.lead.warn" : "p.lead", {}, lead) : null,
rows,
error,
notice,
button,
links.length
? el("p.switch", {}, links.flatMap((l, i) => [
i > 0 ? " · " : null,
el("a", { href: "#", onclick: (ev) => { ev.preventDefault(); l.action(); } },
l.label),
]))
: null
);
}
export function loginView(root, { goto, onSignedIn }) {
const params = new URLSearchParams(location.search);
const verified = params.get("verified");
const emailChange = params.get("adresswechsel");
const node = form({
title: state.appName,
lead: "Bitte anmelden.",
fields: [
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
{ name: "password", label: "Passwort", type: "password", autocomplete: "current-password" },
],
submitLabel: "Anmelden",
onSubmit: async ({ email, password }) => {
const user = await post("/api/auth/login", { email: email.trim(), password });
onSignedIn(user);
},
links: [
{ label: "Konto anlegen", action: () => goto("register") },
{ label: "Passwort vergessen", action: () => goto("forgot") },
],
});
mount(root, node);
if (verified === "ok") {
const notice = node.querySelector(".notice");
notice.textContent = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden.";
notice.hidden = false;
history.replaceState(null, "", "/");
} else if (verified === "invalid") {
const error = node.querySelector(".error");
error.textContent = "Der Bestätigungslink ist ungültig oder abgelaufen.";
error.hidden = false;
history.replaceState(null, "", "/");
}
if (emailChange) {
const messages = {
fertig: ["notice",
"E-Mail-Adresse geändert. Bitte melde dich mit der neuen Adresse an."],
teilweise: ["notice",
"Bestätigung angekommen. Bei Administratorkonten muss auch die " +
"zweite Adresse zustimmen erst danach wird die Änderung wirksam."],
abgelaufen: ["error", "Der Bestätigungslink ist abgelaufen."],
erledigt: ["notice", "Dieser Adresswechsel ist bereits abgeschlossen."],
unbekannt: ["error", "Dieser Bestätigungslink ist unbekannt."],
};
const entry = messages[emailChange];
if (entry) {
const target = node.querySelector(entry[0] === "error" ? ".error" : ".notice");
target.textContent = entry[1];
target.hidden = false;
history.replaceState(null, "", "/");
}
}
node.querySelector("input").focus();
}
export function registerView(root, { goto }) {
mount(root, form({
title: "Konto anlegen",
fields: [
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
{ name: "display_name", label: "Anzeigename", hint: "(freiwillig)", maxLength: 80 },
{ name: "password", label: "Passwort", hint: "(mindestens 12 Zeichen)",
type: "password", autocomplete: "new-password" },
],
submitLabel: "Registrieren",
onSubmit: async (v, { showNotice }) => {
const result = await post("/api/auth/register", {
email: v.email.trim(),
password: v.password,
display_name: v.display_name.trim() || null,
});
showNotice(result.detail);
},
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
}));
}
export function forgotView(root, { goto }) {
mount(root, form({
title: "Passwort zurücksetzen",
lead: "Wir schicken dir einen Link, sofern die Adresse bei uns registriert ist.",
fields: [
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
],
submitLabel: "Link anfordern",
onSubmit: async ({ email }, { showNotice }) => {
const result = await post("/api/auth/password/reset-request", { email: email.trim() });
showNotice(result.detail);
},
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
}));
}
export function resetView(root, { goto }) {
const token = new URLSearchParams(location.search).get("token") || "";
mount(root, form({
title: "Neues Passwort setzen",
fields: [
{ name: "password", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
type: "password", autocomplete: "new-password" },
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
],
submitLabel: "Passwort setzen",
onSubmit: async ({ password, repeat }, { showNotice }) => {
if (password !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
const result = await post("/api/auth/password/reset", { token, password });
showNotice(result.detail);
history.replaceState(null, "", "/");
setTimeout(() => goto("login"), 1500);
},
}));
}
export function changePasswordView(root, { onDone, onLogout }) {
mount(root, form({
title: "Passwort ändern",
lead: "Dieses Konto wurde mit einem Startpasswort aus der Konfiguration angelegt. " +
"Bevor du weiterarbeiten kannst, musst du ein eigenes Passwort setzen.",
leadWarn: true,
fields: [
{ name: "current", label: "Bisheriges Passwort", type: "password",
autocomplete: "current-password" },
{ name: "next", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
type: "password", autocomplete: "new-password" },
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
],
submitLabel: "Passwort ändern",
onSubmit: async ({ current, next, repeat }) => {
if (next !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
await post("/api/auth/password/change", {
current_password: current,
new_password: next,
});
const user = await get("/api/auth/me");
set({ user });
onDone(user);
},
links: [{ label: "Abmelden", action: onLogout }],
}));
}
export async function logout() {
try {
await post("/api/auth/logout");
} catch (err) {
// Auch wenn der Server nicht erreichbar ist: lokal abmelden.
if (!(err instanceof ApiError)) { /* offline - egal */ }
}
set({ user: null, lists: [], view: null });
}