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:
marcandClaude Opus 5 committed 2026-08-24 15:20:55 +02:00
1 parent 84cc5d619e
commit 283f9a1850
20 files changed
+1831 -438

No files matched your search

-26
View File
@@ -1,26 +0,0 @@
{
"user": "marc",
"hostname": "honeyos",
"version": "0.1",
"apps": [
{
"id": "aether",
"name": "AETHER",
"subtitle": "Cloud & Dateien",
"url": "https://cloud.honeyy.duckdns.org",
"glyph": "cloud",
"key": "1",
"me": "/api/v1/auth/me"
},
{
"id": "inknote",
"name": "INKNOTE",
"subtitle": "Notizen & Zeichnen",
"url": "https://notes.honeyy.duckdns.org",
"glyph": "note",
"key": "2",
"me": "/api/auth/me",
"tokenKey": "inknote_token"
}
]
}
+1 -1
View File
@@ -36,6 +36,6 @@
<p>Eine App, die mit <code>100vh</code> rechnet, nimmt <code>--honeybar-h</code> ab.</p>
</main>
<script src="./honeybar.js" data-app="inknote" data-home="../index.html"></script>
<script src="./honeybar.js" data-app="inknote" data-home="/"></script>
</body>
</html>
+18 -15
View File
@@ -12,7 +12,7 @@
*
* data-Attribute am <script>:
* data-app (Pflicht) id der App, in der die Leiste steckt
* data-apps URL zu einer config.json; ohne das gilt die Liste unten
* data-apps URL der App-Liste; ohne das /api/apps neben dem Skript
* data-home URL des HoneyOS-Desktops
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
* "none" legt die Leiste darüber
@@ -34,18 +34,18 @@
const BAR_H = 32;
const LS_HIDDEN = "honeybar.hidden";
const DEFAULT_APPS = [
{ id: "honeyos", name: "DESKTOP", url: "https://os.honeyy.duckdns.org" },
{ id: "aether", name: "AETHER", url: "https://cloud.honeyy.duckdns.org" },
{ id: "inknote", name: "INKNOTE", url: "https://notes.honeyy.duckdns.org" },
];
// Notliste, falls der Server nichts herausgibt: nur der Weg zurück zum
// Schreibtisch. Erfundene App-Adressen stünden hier sonst ewig fest.
const DEFAULT_APPS = [{ id: "honeyos", name: "DESKTOP", url: "" }];
const current = d("app", "");
const homeUrl = d("home", "https://os.honeyy.duckdns.org");
// Ohne data-home führt der Weg zurück dorthin, wo dieses Skript herkommt.
const homeUrl = d("home", script ? new URL("/", script.src).href : "/");
const pushMode = d("push", "body");
// Ohne data-apps holt die Leiste die Konfiguration neben ihrem eigenen
// Skript — dieselbe Datei, die der Desktop liest.
const appsUrl = d("apps", script ? new URL("../config.json", script.src).href : "");
// Ohne data-apps fragt die Leiste den HoneyOS-Server, von dem sie selbst
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
// nur an Ursprünge, die selbst als App eingetragen sind.
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
let apps = DEFAULT_APPS;
@@ -268,15 +268,18 @@
if (appsUrl) {
try {
const res = await fetch(appsUrl, { cache: "no-store" });
// credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist
// dieselbe Site wie diese hier — sonst käme das Cookie nicht mit.
const res = await fetch(appsUrl, { cache: "no-store", credentials: "include" });
if (res.ok) {
const cfg = await res.json();
if (Array.isArray(cfg.apps) && cfg.apps.length) {
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(cfg.apps);
const list = await res.json();
if (Array.isArray(list) && list.length) {
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(list);
}
}
} catch {
/* Liste nicht erreichbar → eingebaute Liste, Leiste funktioniert trotzdem */
/* nicht angemeldet, kein CORS, kein Netz → eingebaute Liste;
die Leiste bleibt ein Umschalter, nur ohne frische Namen */
}
}
+12 -1
View File
@@ -17,6 +17,15 @@
<p class="boot-hint">[ beliebige Taste = überspringen ]</p>
</div>
<!-- ── Anmeldung ──────────────────────────────────────────────────── -->
<div class="login" id="login" hidden>
<pre class="boot-log" id="loginLog"></pre>
<div class="login-box">
<button class="login-btn" id="btnLogin">&#9656; <span id="loginLabel">Anmelden</span></button>
<p class="login-note" id="loginNote"></p>
</div>
</div>
<!-- ── Desktop ────────────────────────────────────────────────────── -->
<div class="desktop" id="desktop" hidden>
<!-- Menüleiste: dieselbe Anatomie wie honeybar.js in den Apps -->
@@ -26,9 +35,11 @@
</button>
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
<div class="mb-right">
<span class="mb-user" id="mbUser">marc</span>
<img class="mb-avatar" id="mbAvatar" alt="" hidden />
<span class="mb-user" id="mbUser">&nbsp;</span>
<span class="mb-clock" id="clock">--:--</span>
<button class="mb-btn" id="btnSettings" title="Einstellungen (S)">&#9881;</button>
<button class="mb-btn" id="btnLogout" title="Abmelden" hidden>&#9211;</button>
</div>
</header>
+293 -76
View File
@@ -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 &amp; 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.
+123
View File
@@ -762,3 +762,126 @@ body {
.task-btn { min-width: 0; }
.task-date { display: none; }
}
/* ── Anmeldung ─────────────────────────────────────────────────────────────
* Derselbe Bildschirm wie der Boot, nur endet er nicht von selbst. Wer nicht
* angemeldet ist, sieht vom Schreibtisch nichts — auch nicht die App-Liste.
*/
.login {
flex: 1;
display: flex;
flex-direction: column;
padding: clamp(16px, 5vw, 56px);
overflow: hidden;
}
.login-box {
margin-top: 26px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.login-btn {
background: none;
border: 1px solid var(--p);
color: var(--p);
font: inherit;
font-size: 15px;
letter-spacing: 0.14em;
padding: 10px 22px;
cursor: pointer;
animation: pulse 2.4s ease-in-out infinite;
}
.login-btn:hover, .login-btn:focus-visible {
background: var(--p);
color: var(--bg);
text-shadow: none;
outline: none;
animation: none;
}
@keyframes pulse {
50% { box-shadow: 0 0 14px color-mix(in srgb, var(--p) 45%, transparent); }
}
.login-note {
margin: 0;
color: var(--p-dim);
font-size: 12px;
max-width: 52ch;
line-height: 1.6;
}
.login-note b { color: var(--p-mid); font-weight: 400; }
/* Nutzer in der Leiste */
.mb-avatar {
width: 18px;
height: 18px;
object-fit: cover;
border: 1px solid var(--p-dim);
}
.mb-avatar[hidden] { display: none; }
/* ── App-Verwaltung ────────────────────────────────────────────────────── */
.apped { display: flex; flex-direction: column; gap: 10px; }
.approw {
border: 1px solid var(--p-dim);
background: var(--bg);
}
.approw > summary {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 9px;
cursor: pointer;
list-style: none;
font-size: 13px;
}
.approw > summary::-webkit-details-marker { display: none; }
.approw[open] > summary { border-bottom: 1px solid var(--p-dim); background: var(--p-faint); }
.approw .ae-glyph { width: 18px; height: 18px; flex: none; }
.approw .ae-glyph svg { display: block; width: 100%; height: 100%; }
.approw .ae-url { margin-left: auto; color: var(--p-dim); font-size: 11px; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.approw .ae-body { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.ae-field { display: grid; grid-template-columns: 92px 1fr; gap: 8px; align-items: center; }
.ae-field > span { font-size: 11px; color: var(--p-dim); letter-spacing: 0.1em; }
.ae-field input, .ae-field select {
background: var(--bg-raised);
border: 1px solid var(--p-dim);
color: var(--p);
font: inherit;
font-size: 12.5px;
padding: 5px 7px;
min-width: 0;
}
.ae-field input:focus, .ae-field select:focus { outline: none; border-color: var(--p); }
/* Hinweise stehen unter ihrem Feld, bündig mit der Eingabe — nicht daneben. */
.ae-hint { margin: -4px 0 0 100px; color: var(--p-dim); font-size: 11px; }
@media (max-width: 520px) {
.ae-field { grid-template-columns: 1fr; gap: 3px; }
.ae-hint { margin-left: 0; }
}
.ae-actions { display: flex; gap: 6px; justify-content: flex-end; padding-top: 2px; }
.btn-danger { border-color: var(--p-dim); color: var(--p-mid); }
.btn-danger:hover { border-color: #d0563a; color: #ff7a5c; background: none; }
.apped-empty {
border: 1px dashed var(--p-dim);
padding: 18px;
text-align: center;
color: var(--p-dim);
font-size: 12.5px;
}
.saveline {
min-height: 16px;
font-size: 11.5px;
color: var(--p-mid);
}
.saveline[data-kind="err"] { color: #ff7a5c; }