feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server
Aus der statischen Seite wird ein Go-Binary mit eingebetteter Oberfläche. Grund: Apps über .env zu pflegen wurde unübersichtlich, und kosmetische Einstellungen sollen auf jedem Gerät gleich sein. - Anmeldung über den eigenen OIDC-Anbieter (PocketID), mit PKCE. Ohne Anmeldung gibt es nichts zu sehen – auch keine App-Liste. - Sitzung als signiertes Cookie ohne Gegenstück im Server; Schreibzugriffe zusätzlich über ein lesbares CSRF-Cookie abgesichert - Apps werden in der Oberfläche angelegt und bearbeitet, nicht mehr in der .env. Der Server prüft jeden Eintrag und meldet zurück, was er behält. - Einstellungen liegen je Nutzer im Server; localStorage ist nur noch der Vorschuss, damit die Röhre beim Laden nicht in der falschen Farbe blitzt - die Leiste holt die App-Liste aus /api/apps – freigegeben sind allein die Ursprünge, die selbst als App eingetragen sind - Tests fahren einen echten OIDC-Anbieter hoch und gehen den Anmeldeweg einmal ganz durch Ohne HONEYOS_OIDC_ISSUER läuft alles offen – für die Entwicklung, mit deutlicher Warnung beim Start. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84cc5d619e
commit
283f9a1850
20 files changed
+1831
-438
No files matched your search
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
|
||||
const LS_SETTINGS = "honeyos.settings";
|
||||
const LS_CFG = "honeyos.config";
|
||||
const SS_BOOTED = "honeyos.booted";
|
||||
const VERSION = "0.2";
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
mode: "terminal", // terminal | desktop
|
||||
@@ -30,7 +30,9 @@ const DEFAULT_SETTINGS = {
|
||||
};
|
||||
|
||||
const state = {
|
||||
cfg: { user: "marc", hostname: "honeyos", version: "0.1", apps: [] },
|
||||
cfg: { user: "", hostname: "honeyos", version: VERSION, apps: [] },
|
||||
me: null, // {sub, name, picture} sobald angemeldet
|
||||
oidc: false,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
probes: {}, // appId -> "up" | "down" | "unknown"
|
||||
auth: {}, // appId -> {status: "in"|"out"|"blind", name}
|
||||
@@ -116,6 +118,34 @@ const GLYPHS = {
|
||||
"..#.####.#..",
|
||||
"....####....",
|
||||
],
|
||||
globe: [
|
||||
"............",
|
||||
"...######...",
|
||||
"..#..#...#..",
|
||||
".#...#....#.",
|
||||
"############",
|
||||
"#....#.....#",
|
||||
"#....#.....#",
|
||||
"############",
|
||||
".#...#....#.",
|
||||
"..#..#...#..",
|
||||
"...######...",
|
||||
"............",
|
||||
],
|
||||
disk: [
|
||||
"............",
|
||||
"############",
|
||||
"#..######..#",
|
||||
"#..#....#..#",
|
||||
"#..#....#..#",
|
||||
"#..######..#",
|
||||
"#..........#",
|
||||
"#.########.#",
|
||||
"#.#......#.#",
|
||||
"#.#......#.#",
|
||||
"############",
|
||||
"............",
|
||||
],
|
||||
folder: [
|
||||
"............",
|
||||
".####.......",
|
||||
@@ -150,27 +180,49 @@ function glyphSvg(name) {
|
||||
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
|
||||
}
|
||||
|
||||
/* ── Server ────────────────────────────────────────────────────────────────
|
||||
* Ein winziger Aufrufhelfer. Schreibende Aufrufe brauchen die CSRF-Kopfzeile:
|
||||
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
|
||||
* die Seite wirklich geöffnet hat.
|
||||
*/
|
||||
async function api(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
if (opts.method && opts.method !== "GET") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
const tok = cookie("honeyos_csrf");
|
||||
if (tok) headers["X-CSRF-Token"] = tok;
|
||||
}
|
||||
const res = await fetch(path, { credentials: "same-origin", cache: "no-store", ...opts, headers });
|
||||
if (res.status === 204) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error((body && body.error) || `${res.status} ${res.statusText}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
function cookie(name) {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim().split("="))
|
||||
.find(([k]) => k === name)?.[1] || "";
|
||||
}
|
||||
|
||||
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
|
||||
|
||||
async function loadConfig() {
|
||||
let base = null;
|
||||
try {
|
||||
const res = await fetch("./config.json", { cache: "no-store" });
|
||||
if (res.ok) base = await res.json();
|
||||
} catch {
|
||||
/* offline oder file:// — dann zählt allein der lokale Stand */
|
||||
}
|
||||
if (base) state.cfg = { ...state.cfg, ...base };
|
||||
/** Holt Nutzer, Einstellungen und Apps. Wirft bei fehlender Anmeldung. */
|
||||
async function loadFromServer() {
|
||||
const me = await api("/api/me");
|
||||
state.me = { sub: me.sub, name: me.name, picture: me.picture };
|
||||
state.oidc = !!me.oidc;
|
||||
state.cfg.user = me.name || "";
|
||||
|
||||
const saved = readJson(LS_CFG);
|
||||
if (saved) {
|
||||
// Gespeichert wird nur, was der Nutzer wirklich geändert hat: Name und URLs.
|
||||
if (saved.user) state.cfg.user = saved.user;
|
||||
if (saved.urls) {
|
||||
state.cfg.apps = state.cfg.apps.map((a) => (saved.urls[a.id] ? { ...a, url: saved.urls[a.id] } : a));
|
||||
}
|
||||
}
|
||||
// Der Server hat das letzte Wort; localStorage war nur der Vorschuss.
|
||||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}), ...(me.settings || {}) };
|
||||
state.cfg.apps = await api("/api/apps");
|
||||
}
|
||||
|
||||
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
||||
* aufblitzt und dann umspringt. */
|
||||
function loadLocalSettings() {
|
||||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
|
||||
}
|
||||
|
||||
@@ -182,23 +234,24 @@ function readJson(key) {
|
||||
}
|
||||
}
|
||||
|
||||
let syncTimer = null;
|
||||
|
||||
function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
|
||||
} catch {
|
||||
/* Privatmodus: Einstellungen gelten dann nur für diese Sitzung */
|
||||
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
|
||||
}
|
||||
applySettings();
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
const urls = {};
|
||||
state.cfg.apps.forEach((a) => (urls[a.id] = a.url));
|
||||
try {
|
||||
localStorage.setItem(LS_CFG, JSON.stringify({ user: state.cfg.user, urls }));
|
||||
} catch {
|
||||
/* siehe oben */
|
||||
}
|
||||
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
|
||||
// soll nicht dreimal schreiben lassen.
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => {
|
||||
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
|
||||
console.warn("Einstellungen nicht gespeichert:", e.message)
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
@@ -218,7 +271,7 @@ function bootLines() {
|
||||
{ t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 },
|
||||
{ t: "", d: 40 },
|
||||
{ t: "prüfe speicher".padEnd(28, ".") + " 640K ok", d: 120 },
|
||||
{ t: "lade benutzerprofil".padEnd(28, ".") + " " + state.cfg.user, d: 120 },
|
||||
{ t: "prüfe anmeldung".padEnd(28, ".") + " " + (state.cfg.user || "—"), d: 120 },
|
||||
...apps.map((a) => ({ t: `binde ${a.id} ein`.padEnd(28, ".") + " [ ok ]", d: 140 })),
|
||||
{ t: "", d: 60 },
|
||||
{ t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 },
|
||||
@@ -280,6 +333,11 @@ function greeting() {
|
||||
function renderShell() {
|
||||
$("desktop").hidden = false;
|
||||
$("mbUser").textContent = state.cfg.user;
|
||||
if (state.me && state.me.picture) {
|
||||
$("mbAvatar").src = state.me.picture;
|
||||
$("mbAvatar").hidden = false;
|
||||
}
|
||||
$("btnLogout").hidden = !state.oidc;
|
||||
renderMenubarApps();
|
||||
renderMode();
|
||||
tickClock();
|
||||
@@ -525,8 +583,10 @@ function renderTiles() {
|
||||
|
||||
const add = document.createElement("button");
|
||||
add.className = "tile tile-add";
|
||||
add.innerHTML = "<span>+ app hinzufügen</span>";
|
||||
add.addEventListener("click", openSettings);
|
||||
add.innerHTML = state.cfg.apps.length
|
||||
? "<span>+ app hinzufügen</span>"
|
||||
: "<span>Noch keine App.<br />Hier die erste anlegen.</span>";
|
||||
add.addEventListener("click", openApps);
|
||||
wrap.appendChild(add);
|
||||
}
|
||||
|
||||
@@ -744,7 +804,7 @@ function openProgramsWindow() {
|
||||
title: "programme",
|
||||
w: 360,
|
||||
body: `<div class="applist">${rows}
|
||||
<button data-settings="1"><span class="al-glyph">${glyphSvg("gear")}</span><span>app hinzufügen …</span></button>
|
||||
<button data-settings="1"><span class="al-glyph">${glyphSvg("gear")}</span><span>programme verwalten …</span></button>
|
||||
</div>`,
|
||||
onMount: (root) => {
|
||||
root.querySelectorAll("[data-launch]").forEach((b) =>
|
||||
@@ -753,7 +813,7 @@ function openProgramsWindow() {
|
||||
if (app) launch(app);
|
||||
})
|
||||
);
|
||||
root.querySelector("[data-settings]").addEventListener("click", openSettings);
|
||||
root.querySelector("[data-settings]").addEventListener("click", openApps);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -791,11 +851,6 @@ function openSettings() {
|
||||
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Name</span>
|
||||
<input type="text" id="setUser" value="${escapeAttr(state.cfg.user)}" maxlength="24" />
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Röhre</span>
|
||||
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
|
||||
@@ -816,20 +871,14 @@ function openSettings() {
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Adressen</span>
|
||||
${state.cfg.apps
|
||||
.map(
|
||||
(a) =>
|
||||
`<div class="url-row"><span>${escapeHtml(a.name)}</span>
|
||||
<input type="url" data-url="${a.id}" value="${escapeAttr(a.url)}" spellcheck="false" /></div>`
|
||||
)
|
||||
.join("")}
|
||||
<span class="label">Programme</span>
|
||||
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" id="btnReset">zurücksetzen</button>
|
||||
<button class="btn btn-primary" id="btnSave">speichern</button>
|
||||
</div>`,
|
||||
<p class="login-note" style="margin-top:4px">
|
||||
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||||
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
|
||||
</p>`,
|
||||
(root) => {
|
||||
root.querySelectorAll(".seg").forEach((segEl) => {
|
||||
segEl.addEventListener("click", (e) => {
|
||||
@@ -852,33 +901,117 @@ function openSettings() {
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("#btnSave").addEventListener("click", () => {
|
||||
const user = root.querySelector("#setUser").value.trim();
|
||||
if (user) state.cfg.user = user;
|
||||
root.querySelectorAll("input[data-url]").forEach((inp) => {
|
||||
const app = state.cfg.apps.find((a) => a.id === inp.dataset.url);
|
||||
const val = inp.value.trim();
|
||||
if (app && val) app.url = val;
|
||||
});
|
||||
saveConfig();
|
||||
if (state.settings.mode === "desktop") closeWindow("settings");
|
||||
else closeModal();
|
||||
$("mbUser").textContent = state.cfg.user;
|
||||
renderMenubarApps();
|
||||
renderMode();
|
||||
probeApps();
|
||||
});
|
||||
|
||||
root.querySelector("#btnReset").addEventListener("click", () => {
|
||||
localStorage.removeItem(LS_SETTINGS);
|
||||
localStorage.removeItem(LS_CFG);
|
||||
location.reload();
|
||||
});
|
||||
root.querySelector("#btnApps").addEventListener("click", openApps);
|
||||
},
|
||||
420
|
||||
);
|
||||
}
|
||||
|
||||
/* ── App-Verwaltung ────────────────────────────────────────────────────────
|
||||
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
|
||||
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
|
||||
* wirklich behalten hat — das ist danach der Stand, nicht das Formular.
|
||||
*/
|
||||
function openApps() {
|
||||
let draft = state.cfg.apps.map((a) => ({ ...a }));
|
||||
|
||||
const GLYPHS_LIST = ["cloud", "note", "box", "chip", "folder", "gear", "globe", "disk"];
|
||||
|
||||
const rowHtml = (a, i) => `
|
||||
<details class="approw" ${a.__neu ? "open" : ""}>
|
||||
<summary>
|
||||
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
|
||||
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
|
||||
<span class="ae-url">${escapeHtml(a.url || "")}</span>
|
||||
</summary>
|
||||
<div class="ae-body">
|
||||
<label class="ae-field"><span>Kennung</span>
|
||||
<input data-i="${i}" data-f="id" value="${escapeAttr(a.id || "")}" placeholder="aether" spellcheck="false" /></label>
|
||||
<p class="ae-hint">klein, ohne Leerzeichen — muss zu <code>data-app</code> der Leiste passen</p>
|
||||
<label class="ae-field"><span>Name</span>
|
||||
<input data-i="${i}" data-f="name" value="${escapeAttr(a.name || "")}" placeholder="AETHER" /></label>
|
||||
<label class="ae-field"><span>Untertitel</span>
|
||||
<input data-i="${i}" data-f="subtitle" value="${escapeAttr(a.subtitle || "")}" placeholder="Cloud & Dateien" /></label>
|
||||
<label class="ae-field"><span>Adresse</span>
|
||||
<input data-i="${i}" data-f="url" value="${escapeAttr(a.url || "")}" placeholder="https://…" spellcheck="false" /></label>
|
||||
<label class="ae-field"><span>Symbol</span>
|
||||
<select data-i="${i}" data-f="glyph">
|
||||
${GLYPHS_LIST.map((g) => `<option value="${g}" ${a.glyph === g ? "selected" : ""}>${g}</option>`).join("")}
|
||||
</select></label>
|
||||
<label class="ae-field"><span>Taste</span>
|
||||
<input data-i="${i}" data-f="key" value="${escapeAttr(a.key || "")}" maxlength="1" placeholder="1" /></label>
|
||||
<label class="ae-field"><span>Wer-bin-ich</span>
|
||||
<input data-i="${i}" data-f="me" value="${escapeAttr(a.me || "")}" placeholder="/api/v1/auth/me" spellcheck="false" /></label>
|
||||
<p class="ae-hint">optional — zeigt an, ob du dort angemeldet bist</p>
|
||||
<label class="ae-field"><span>Token-Schlüssel</span>
|
||||
<input data-i="${i}" data-f="tokenKey" value="${escapeAttr(a.tokenKey || "")}" placeholder="leer lassen" spellcheck="false" /></label>
|
||||
<p class="ae-hint">nur für Apps mit Bearer-Token im localStorage statt Cookie</p>
|
||||
<div class="ae-actions">
|
||||
<button class="btn btn-danger" data-del="${i}">löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>`;
|
||||
|
||||
const render = (root) => {
|
||||
const list = root.querySelector("#appList");
|
||||
list.innerHTML = draft.length
|
||||
? draft.map(rowHtml).join("")
|
||||
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`;
|
||||
|
||||
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
|
||||
inp.addEventListener("input", () => {
|
||||
draft[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll("[data-del]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
draft.splice(Number(b.dataset.del), 1);
|
||||
render(root);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
openPanel(
|
||||
"apps",
|
||||
"programme verwalten",
|
||||
`<div class="apped"><div id="appList"></div>
|
||||
<div class="modal-actions">
|
||||
<span class="saveline" id="appMsg"></span>
|
||||
<button class="btn" id="btnAdd">+ neue app</button>
|
||||
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
|
||||
</div>
|
||||
</div>`,
|
||||
(root) => {
|
||||
render(root);
|
||||
|
||||
root.querySelector("#btnAdd").addEventListener("click", () => {
|
||||
draft.push({ id: "", name: "", url: "", glyph: "box", key: String(draft.length + 1), __neu: true });
|
||||
render(root);
|
||||
});
|
||||
|
||||
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
|
||||
const msg = root.querySelector("#appMsg");
|
||||
msg.dataset.kind = "";
|
||||
msg.textContent = "speichere …";
|
||||
try {
|
||||
const clean = draft.map(({ __neu, ...rest }) => rest);
|
||||
state.cfg.apps = await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) });
|
||||
draft = state.cfg.apps.map((a) => ({ ...a }));
|
||||
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
|
||||
render(root);
|
||||
renderMenubarApps();
|
||||
renderMode();
|
||||
probeApps();
|
||||
} catch (e) {
|
||||
msg.dataset.kind = "err";
|
||||
msg.textContent = e.message;
|
||||
}
|
||||
});
|
||||
},
|
||||
460
|
||||
);
|
||||
}
|
||||
|
||||
function openAbout() {
|
||||
openPanel(
|
||||
"about",
|
||||
@@ -930,7 +1063,8 @@ function buildStartMenu() {
|
||||
<button data-win="system">System</button>
|
||||
<button data-win="settings">Einstellungen</button>
|
||||
<button data-win="about">Über HoneyOS</button>
|
||||
<hr /><button data-mode="terminal">↩ Terminal-Modus</button>`;
|
||||
<hr /><button data-mode="terminal">↩ Terminal-Modus</button>
|
||||
<button data-logout="1">⏻ Abmelden</button>`;
|
||||
|
||||
menu.querySelectorAll("[data-app]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
@@ -951,6 +1085,7 @@ function buildStartMenu() {
|
||||
closeStartMenu();
|
||||
setMode("terminal");
|
||||
});
|
||||
menu.querySelector("[data-logout]").addEventListener("click", logout);
|
||||
|
||||
menu.dataset.open = "1";
|
||||
btn.setAttribute("aria-expanded", "true");
|
||||
@@ -968,10 +1103,91 @@ function closeStartMenu() {
|
||||
|
||||
/* ── Start ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Anmeldeschirm ─────────────────────────────────────────────────────────
|
||||
* Kein Formular: HoneyOS kennt keine Passwörter. Es gibt genau einen Knopf,
|
||||
* und der schickt zum Anbieter. Der Rest ist Kulisse — aber Kulisse, die sagt,
|
||||
* was schiefging, wenn der Rückweg gescheitert ist.
|
||||
*/
|
||||
const LOGIN_ERRORS = {
|
||||
anbieter: "Der Anmeldedienst war nicht erreichbar.",
|
||||
abgelaufen: "Der Versuch hat zu lange gedauert. Noch einmal.",
|
||||
state: "Die Antwort passte nicht zur Anfrage. Noch einmal.",
|
||||
kein_code: "Der Anbieter hat keinen Code geschickt.",
|
||||
tausch: "Der Code ließ sich nicht einlösen — Client-Kennung oder Geheimnis prüfen.",
|
||||
kein_id_token: "Der Anbieter hat kein ID-Token geschickt.",
|
||||
token: "Das ID-Token war ungültig.",
|
||||
claims: "Das ID-Token nannte keinen Nutzer.",
|
||||
nicht_erlaubt: "Dieses Konto steht nicht auf der erlaubten Liste.",
|
||||
};
|
||||
|
||||
function showLogin(pub) {
|
||||
const el = $("login");
|
||||
const log = $("loginLog");
|
||||
el.hidden = false;
|
||||
|
||||
const fehler = new URLSearchParams(location.search).get("fehler");
|
||||
const lines = [
|
||||
`HoneyOS ${VERSION} — ${state.cfg.hostname}`,
|
||||
"",
|
||||
"prüfe sitzung".padEnd(28, ".") + " keine",
|
||||
"anmeldung erforderlich",
|
||||
];
|
||||
log.textContent = lines.join("\n") + "\n";
|
||||
|
||||
const note = $("loginNote");
|
||||
if (fehler) {
|
||||
note.innerHTML = `<b>${escapeHtml(LOGIN_ERRORS[fehler] || "Die Anmeldung ist gescheitert (" + fehler + ").")}</b>`;
|
||||
} else {
|
||||
note.textContent = "Die Anmeldung läuft über deinen Anbieter. HoneyOS speichert kein Passwort.";
|
||||
}
|
||||
|
||||
const btn = $("btnLogin");
|
||||
$("loginLabel").textContent = pub.buttonLabel || "Anmelden";
|
||||
btn.addEventListener("click", () => {
|
||||
btn.disabled = true;
|
||||
$("loginLabel").textContent = "weiterleiten …";
|
||||
location.href = "/api/auth/login";
|
||||
});
|
||||
btn.focus();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
/* egal — die Sitzung ist so oder so nichts mehr wert */
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem(SS_BOOTED);
|
||||
} catch {
|
||||
/* dann bootet es beim nächsten Mal eben neu */
|
||||
}
|
||||
location.href = "/";
|
||||
}
|
||||
|
||||
/* ── Start ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function main() {
|
||||
await loadConfig();
|
||||
// Einstellungen aus dem letzten Besuch gelten sofort, damit die Röhre beim
|
||||
// Laden nicht in der falschen Farbe aufflackert.
|
||||
loadLocalSettings();
|
||||
applySettings();
|
||||
|
||||
let pub = { oidc: false, buttonLabel: "Anmelden" };
|
||||
try {
|
||||
pub = await api("/api/public");
|
||||
} catch {
|
||||
/* Server nicht erreichbar — der Anmeldeschirm sagt es gleich selbst */
|
||||
}
|
||||
|
||||
try {
|
||||
await loadFromServer();
|
||||
} catch {
|
||||
return showLogin(pub); // nicht angemeldet: hier ist Schluss
|
||||
}
|
||||
|
||||
applySettings(); // jetzt gilt, was der Server gespeichert hat
|
||||
|
||||
let skipBoot = false;
|
||||
try {
|
||||
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
|
||||
@@ -988,6 +1204,7 @@ async function main() {
|
||||
|
||||
$("btnSettings").addEventListener("click", openSettings);
|
||||
$("btnAbout").addEventListener("click", openAbout);
|
||||
$("btnLogout").addEventListener("click", logout);
|
||||
$("btnClose").addEventListener("click", closeModal);
|
||||
$("modal").addEventListener("click", (e) => {
|
||||
if (e.target === $("modal")) closeModal();
|
||||
@@ -995,7 +1212,7 @@ async function main() {
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const typing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
|
||||
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
||||
if (e.key === "Escape") {
|
||||
// Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es
|
||||
// einem gleichzeitig das oberste Fenster weg.
|
||||
|
||||
Reference in new issue
Block a user