diff --git a/Brain.md b/Brain.md
index f301304..f25d519 100644
--- a/Brain.md
+++ b/Brain.md
@@ -45,6 +45,7 @@ Aktueller Stand: Die Seite ist keine reine Linkliste mehr, sondern ein kompaktes
- **PWA**: Manifest, Apple-Touch-Icon, App-Icons und Service Worker fuer installierbare iOS/Web-App
- **Dashboard**: Kategorien `Zugang`, `Medien`, `Arbeit`, `Persoenlich`, Suche und clientseitige no-cors Statuschecks
- **Auth-Link**: Zeigt auf die Keycloak Account Console (`/realms/mischlabs/account`), nicht mehr auf die Admin-Konsole
+- **Adminseite**: `/admin.html` nutzt Keycloak Authorization Code + PKCE fuer Login und bietet Editor/Statusdiagnose fuer Dienste
## Docker-Setup
- **Container-Name**: `mischlabs`
@@ -59,6 +60,9 @@ mischlabs/
index.html - Haupt-HTML mit allen 9 Service-Kacheln
style.css - Komplettes Styling (Dark Theme, Grid, Animationen)
app.js - Suche, Filter, Statuschecks, Service-Worker-Registrierung
+ admin.html - SSO-geschuetzte Adminoberflaeche
+ admin.js - Keycloak PKCE Login, Editor, Statusdiagnose
+ services.json - Zentrale Service-/Kategorie-Konfiguration
offline.html - Offline-Fallback fuer installierte PWA
manifest.webmanifest - PWA-Metadaten fuer installierbare App
sw.js - Schlanker Service Worker mit App-Shell-Cache
@@ -105,8 +109,8 @@ mischlabs/
- `icons/icon-512.png` (512x512)
- `icons/maskable-512.png` (512x512, maskable)
- Service Worker:
- - Cache-Name: `mischlabs-pwa-v3`
- - Cached die App-Shell (`/`, `index.html`, `offline.html`, `style.css?v=9`, `app.js?v=1`, Manifest und Icons)
+ - Cache-Name: `mischlabs-pwa-v4`
+ - Cached die App-Shell (`/`, `index.html`, `offline.html`, `style.css?v=10`, `app.js?v=2`, `services.json?v=1`, Manifest und Icons)
- Navigationen fallen offline auf `offline.html` zurueck
- Dockerfile kopiert Manifest, Service Worker und Icons in das Nginx-Image.
- `nginx.conf` liefert `manifest.webmanifest` mit `application/manifest+json` und `sw.js` mit harten No-Cache-Headern aus.
@@ -121,3 +125,19 @@ mischlabs/
- Arbeit: Git, MiChess
- Persoenlich: Tom
- `app.js` prueft Dienste im Hintergrund per `fetch(..., { mode: "no-cors" })`. Das ist bewusst nur ein Erreichbarkeitsindikator, kein vollwertiges Monitoring.
+
+## Adminseite Stand 2026-05-21
+- URL: `https://mischlabs.de/admin.html`
+- SSO-Client in Keycloak benoetigt:
+ - Client ID: `mischlabs-admin`
+ - Client type: OpenID Connect
+ - Client authentication: Off/Public Client
+ - Standard flow: On
+ - Valid redirect URI: `https://mischlabs.de/admin.html`
+ - Valid post logout redirect URI: `https://mischlabs.de/admin.html`
+ - Web origin: `https://mischlabs.de`
+ - Scopes: `openid email profile`
+- Erlaubter Admin in der statischen UI:
+ - `preferred_username=mrdiderot` oder `email=mail.misch@pm.me`
+- Aktuelle Grenze: Weil mischlabs noch rein statisch ueber Nginx laeuft, kann `/admin.html` globale Aenderungen noch nicht serverseitig speichern. Der Editor speichert lokal im Browser (`localStorage`) und kann eine neue `services.json` exportieren. Fuer echte Live-Aenderungen braucht der naechste Schritt ein kleines Backend oder einen Gitea-Commit-Workflow.
+- Statusdiagnose nutzt Browser-Fetch mit `mode: "no-cors"`. Dadurch erkennt sie Erreichbarkeit/Timeouts, aber bei fremden Subdomains keine echten HTTP-Statuscodes. Fuer echte Fehlerdetails braucht es ebenfalls ein Backend.
diff --git a/Dockerfile b/Dockerfile
index a7d4057..0c9f555 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,9 +2,12 @@ FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/
+COPY admin.html /usr/share/nginx/html/
COPY offline.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
+COPY admin.js /usr/share/nginx/html/
+COPY services.json /usr/share/nginx/html/
COPY manifest.webmanifest /usr/share/nginx/html/
COPY sw.js /usr/share/nginx/html/
COPY icons/ /usr/share/nginx/html/icons/
diff --git a/admin.html b/admin.html
new file mode 100644
index 0000000..bb65310
--- /dev/null
+++ b/admin.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+ MischLabs Admin
+
+
+
+
+
+
+
+
+
+
Admin
+
MischLabs
+
Dienste pruefen, Kategorien vorbereiten und Dashboard-Konfiguration verwalten.
+
+
+
+
+
Dashboard
+
Mit Keycloak anmelden
+
Abmelden
+
+
+
+
+
+ SSO
+ Anmeldung erforderlich
+ Diese Seite nutzt Keycloak per Authorization Code + PKCE. Der Client muss in Keycloak als Public Client mit Redirect URI `https://mischlabs.de/admin.html` angelegt sein.
+ Noch nicht angemeldet.
+
+
+
+
+
+
Konfiguration
+
Dienste & Kategorien
+
+
+ Status pruefen
+ Lokal speichern
+ Lokale Aenderungen verwerfen
+ JSON exportieren
+
+
+
+
+ Hinweis: Ohne Backend speichert diese Adminseite Aenderungen lokal im Browser und kann eine neue `services.json` exportieren. Fuer globale Live-Aenderungen bauen wir als naechsten Schritt ein kleines Backend oder einen Gitea-Commit-Workflow.
+
+
+
+
+
+
+
+
+
+
diff --git a/admin.js b/admin.js
new file mode 100644
index 0000000..7f6f5c5
--- /dev/null
+++ b/admin.js
@@ -0,0 +1,318 @@
+const AUTHORITY = 'https://auth.mischlabs.de/realms/mischlabs';
+const CLIENT_ID = 'mischlabs-admin';
+const REDIRECT_URI = `${window.location.origin}/admin.html`;
+const SCOPES = 'openid profile email';
+const CONFIG_URL = '/services.json?v=1';
+const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
+const TOKEN_KEY = 'mischlabs.admin.tokens';
+const PKCE_KEY = 'mischlabs.admin.pkce';
+
+const loginButton = document.querySelector('#loginButton');
+const logoutButton = document.querySelector('#logoutButton');
+const loginPanel = document.querySelector('#loginPanel');
+const adminPanel = document.querySelector('#adminPanel');
+const loginMessage = document.querySelector('#loginMessage');
+const adminUser = document.querySelector('#adminUser');
+const serviceEditor = document.querySelector('#serviceEditor');
+const checkAllButton = document.querySelector('#checkAllButton');
+const saveLocalButton = document.querySelector('#saveLocalButton');
+const resetLocalButton = document.querySelector('#resetLocalButton');
+const downloadConfigButton = document.querySelector('#downloadConfigButton');
+
+let config = null;
+let tokenSet = null;
+
+function base64UrlEncode(buffer) {
+ return btoa(String.fromCharCode(...new Uint8Array(buffer)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/g, '');
+}
+
+function randomString(length = 64) {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+ const bytes = new Uint8Array(length);
+ crypto.getRandomValues(bytes);
+ return [...bytes].map((byte) => chars[byte % chars.length]).join('');
+}
+
+async function createChallenge(verifier) {
+ const data = new TextEncoder().encode(verifier);
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ return base64UrlEncode(digest);
+}
+
+function decodeJwt(token) {
+ const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ const json = decodeURIComponent(atob(payload).split('').map((char) => (
+ `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`
+ )).join(''));
+ return JSON.parse(json);
+}
+
+function getStoredTokens() {
+ try {
+ const raw = sessionStorage.getItem(TOKEN_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+function storeTokens(tokens) {
+ sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
+}
+
+function clearTokens() {
+ sessionStorage.removeItem(TOKEN_KEY);
+ sessionStorage.removeItem(PKCE_KEY);
+}
+
+function isTokenValid(tokens) {
+ if (!tokens?.id_token) return false;
+ try {
+ const claims = decodeJwt(tokens.id_token);
+ return Date.now() < claims.exp * 1000;
+ } catch {
+ return false;
+ }
+}
+
+async function login() {
+ const verifier = randomString();
+ const challenge = await createChallenge(verifier);
+ const state = randomString(32);
+ sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state }));
+
+ const params = new URLSearchParams({
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ response_type: 'code',
+ scope: SCOPES,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256'
+ });
+
+ window.location.href = `${AUTHORITY}/protocol/openid-connect/auth?${params}`;
+}
+
+async function handleCallback() {
+ const url = new URL(window.location.href);
+ const code = url.searchParams.get('code');
+ const state = url.searchParams.get('state');
+ const error = url.searchParams.get('error');
+
+ if (error) {
+ loginMessage.textContent = `Keycloak Fehler: ${error}`;
+ window.history.replaceState({}, document.title, REDIRECT_URI);
+ return;
+ }
+
+ if (!code) return;
+
+ const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}');
+ if (!stored.verifier || stored.state !== state) {
+ loginMessage.textContent = 'SSO Antwort konnte nicht verifiziert werden.';
+ return;
+ }
+
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ code,
+ code_verifier: stored.verifier
+ });
+
+ try {
+ const response = await fetch(`${AUTHORITY}/protocol/openid-connect/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body
+ });
+
+ if (!response.ok) {
+ throw new Error(`Token endpoint returned ${response.status}`);
+ }
+
+ const tokens = await response.json();
+ storeTokens(tokens);
+ window.history.replaceState({}, document.title, REDIRECT_URI);
+ } catch (error) {
+ loginMessage.textContent = `Token-Austausch fehlgeschlagen: ${error.message}. Keycloak Client/Web Origins pruefen.`;
+ }
+}
+
+function authorize(tokens) {
+ const claims = decodeJwt(tokens.id_token);
+ const username = String(claims.preferred_username || '').toLowerCase();
+ const email = String(claims.email || '').toLowerCase();
+ const allowed = username === 'mrdiderot' || email === 'mail.misch@pm.me';
+
+ if (!allowed) {
+ loginMessage.textContent = `Angemeldet als ${claims.preferred_username || claims.email}, aber nicht fuer diese Adminseite freigeschaltet.`;
+ return null;
+ }
+
+ return claims;
+}
+
+async function loadConfig() {
+ const local = localStorage.getItem(LOCAL_CONFIG_KEY);
+ if (local) return JSON.parse(local);
+
+ const response = await fetch(CONFIG_URL, { cache: 'no-store' });
+ return response.json();
+}
+
+function categoryOptions(selected) {
+ return config.categories.map((category) => (
+ `${category.label} `
+ )).join('');
+}
+
+function renderEditor() {
+ serviceEditor.innerHTML = config.services.map((service, index) => `
+
+
+ ${service.name}
+ ${service.domain}
+
+
+ Kategorie
+ ${categoryOptions(service.category)}
+
+
+ URL
+
+
+ Pruefen
+ Noch nicht geprueft.
+
+ `).join('');
+
+ serviceEditor.querySelectorAll('select,input').forEach((input) => {
+ input.addEventListener('change', () => {
+ const row = input.closest('.admin-row');
+ const service = config.services[Number(row.dataset.index)];
+ service[input.dataset.field] = input.value;
+ });
+ });
+
+ serviceEditor.querySelectorAll('.check-one').forEach((button) => {
+ button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
+ });
+}
+
+async function probe(url) {
+ const started = performance.now();
+ const controller = new AbortController();
+ const timeout = window.setTimeout(() => controller.abort(), 6000);
+
+ try {
+ const response = await fetch(url, {
+ mode: 'no-cors',
+ cache: 'no-store',
+ signal: controller.signal
+ });
+ return {
+ ok: true,
+ ms: Math.round(performance.now() - started),
+ detail: response.type === 'opaque'
+ ? 'Erreichbar. HTTP-Details wegen Browser-CORS nicht lesbar.'
+ : `HTTP ${response.status}`
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ ms: Math.round(performance.now() - started),
+ detail: error.name === 'AbortError' ? 'Timeout nach 6 Sekunden.' : error.message
+ };
+ } finally {
+ window.clearTimeout(timeout);
+ }
+}
+
+async function checkRow(row) {
+ const service = config.services[Number(row.dataset.index)];
+ const diagnostic = row.querySelector('.diagnostic');
+ diagnostic.textContent = 'Pruefe...';
+ row.classList.remove('is-online', 'is-offline');
+
+ const result = await probe(service.url);
+ row.classList.toggle('is-online', result.ok);
+ row.classList.toggle('is-offline', !result.ok);
+ diagnostic.textContent = `${result.ok ? 'Online' : 'Fehler'} - ${result.ms} ms - ${result.detail}`;
+}
+
+function saveLocal() {
+ localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2));
+}
+
+function downloadConfig() {
+ const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'services.json';
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+async function boot() {
+ await handleCallback();
+ tokenSet = getStoredTokens();
+
+ if (!isTokenValid(tokenSet)) {
+ loginPanel.hidden = false;
+ adminPanel.hidden = true;
+ loginButton.hidden = false;
+ logoutButton.hidden = true;
+ return;
+ }
+
+ const claims = authorize(tokenSet);
+ if (!claims) return;
+
+ config = await loadConfig();
+ loginPanel.hidden = true;
+ adminPanel.hidden = false;
+ loginButton.hidden = true;
+ logoutButton.hidden = false;
+ adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`;
+ renderEditor();
+}
+
+loginButton.addEventListener('click', login);
+logoutButton.addEventListener('click', () => {
+ const idToken = tokenSet?.id_token;
+ clearTokens();
+ const params = new URLSearchParams({
+ client_id: CLIENT_ID,
+ post_logout_redirect_uri: REDIRECT_URI
+ });
+ if (idToken) params.set('id_token_hint', idToken);
+ window.location.href = `${AUTHORITY}/protocol/openid-connect/logout?${params}`;
+});
+
+checkAllButton.addEventListener('click', () => {
+ [...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row));
+});
+
+saveLocalButton.addEventListener('click', () => {
+ saveLocal();
+ window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.');
+});
+
+resetLocalButton.addEventListener('click', () => {
+ localStorage.removeItem(LOCAL_CONFIG_KEY);
+ window.location.reload();
+});
+
+downloadConfigButton.addEventListener('click', downloadConfig);
+
+boot().catch((error) => {
+ loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`;
+});
+
diff --git a/app.js b/app.js
index e109a2c..b3d6422 100644
--- a/app.js
+++ b/app.js
@@ -1,15 +1,114 @@
-const cards = [...document.querySelectorAll('.service-card')];
-const sections = [...document.querySelectorAll('.section')];
+const CONFIG_URL = '/services.json?v=1';
+const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
+
+const icons = {
+ shield: ' ',
+ folder: ' ',
+ lock: ' ',
+ film: ' ',
+ headphones: ' ',
+ book: ' ',
+ git: ' ',
+ chess: ' ',
+ user: ' '
+};
+
+let cards = [];
+let sections = [];
+let config = null;
+let activeFilter = 'all';
+
const searchInput = document.querySelector('#serviceSearch');
const segments = [...document.querySelectorAll('.segment')];
const emptyState = document.querySelector('#emptyState');
const onlineCount = document.querySelector('#onlineCount');
+const serviceCount = document.querySelector('#serviceCount');
const lastChecked = document.querySelector('#lastChecked');
-
-let activeFilter = 'all';
+const dashboardSections = document.querySelector('#dashboardSections');
function normalize(value) {
- return value.toLowerCase().trim();
+ return String(value || '').toLowerCase().trim();
+}
+
+function getLocalConfig() {
+ try {
+ const raw = window.localStorage.getItem(LOCAL_CONFIG_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+async function loadConfig() {
+ const response = await fetch(CONFIG_URL, { cache: 'no-store' });
+ const baseConfig = await response.json();
+ return getLocalConfig() || baseConfig;
+}
+
+function serviceSearchText(service) {
+ return [
+ service.name,
+ service.description,
+ service.domain,
+ service.category,
+ service.keywords
+ ].join(' ');
+}
+
+function createServiceCard(service) {
+ const card = document.createElement('a');
+ card.href = service.url;
+ card.target = '_blank';
+ card.rel = 'noopener';
+ card.className = 'card service-card';
+ card.dataset.category = service.category;
+ card.dataset.url = service.url;
+ card.dataset.name = serviceSearchText(service);
+ card.innerHTML = `
+
+
+
+ ${icons[service.icon] || icons.folder}
+
+
+
+
${service.name}
+
${service.description}
+
+ ${service.domain}
+ `;
+ return card;
+}
+
+function renderDashboard(nextConfig) {
+ dashboardSections.innerHTML = '';
+ serviceCount.textContent = String(nextConfig.services.length);
+
+ nextConfig.categories.forEach((category) => {
+ const services = nextConfig.services.filter((service) => service.category === category.id);
+ if (!services.length) return;
+
+ const section = document.createElement('section');
+ section.className = 'section';
+ section.dataset.category = category.id;
+ section.innerHTML = `
+
+
+
${category.label}
+
${category.title}
+
+
${services.length} ${services.length === 1 ? 'Dienst' : 'Dienste'}
+
+
+ `;
+
+ const grid = section.querySelector('.grid');
+ services.forEach((service) => grid.appendChild(createServiceCard(service)));
+ dashboardSections.appendChild(section);
+ });
+
+ cards = [...document.querySelectorAll('.service-card')];
+ sections = [...document.querySelectorAll('.section')];
}
function updateVisibility() {
@@ -77,6 +176,19 @@ async function refreshStatus() {
})}`;
}
+function scheduleStatusRefresh() {
+ const run = () => refreshStatus().catch(() => {
+ onlineCount.textContent = '--';
+ lastChecked.textContent = 'Status aktuell nicht verfuegbar.';
+ });
+
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(run, { timeout: 2000 });
+ } else {
+ window.setTimeout(run, 600);
+ }
+}
+
segments.forEach((segment) => {
segment.addEventListener('click', () => setActiveFilter(segment.dataset.filter));
});
@@ -89,18 +201,14 @@ if ('serviceWorker' in navigator) {
});
}
-window.addEventListener('load', () => {
- const run = () => refreshStatus().catch(() => {
+loadConfig()
+ .then((nextConfig) => {
+ config = nextConfig;
+ renderDashboard(config);
+ updateVisibility();
+ scheduleStatusRefresh();
+ })
+ .catch(() => {
+ dashboardSections.innerHTML = 'Dashboard-Konfiguration konnte nicht geladen werden.
';
onlineCount.textContent = '--';
- lastChecked.textContent = 'Status aktuell nicht verfuegbar.';
});
-
- if ('requestIdleCallback' in window) {
- window.requestIdleCallback(run, { timeout: 2000 });
- } else {
- window.setTimeout(run, 600);
- }
-});
-
-updateVisibility();
-
diff --git a/index.html b/index.html
index 92e6ee6..b4c10dd 100644
--- a/index.html
+++ b/index.html
@@ -13,7 +13,7 @@
-
+