feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s

This commit is contained in:
Kroonk
2026-05-21 23:18:23 +02:00
parent fb540813ff
commit d40329ae7c
12 changed files with 1039 additions and 10 deletions

View File

@@ -4,10 +4,13 @@ WORKDIR /app
COPY server.js /app/server.js COPY server.js /app/server.js
COPY index.html /app/public/ COPY index.html /app/public/
COPY favorites.html /app/public/
COPY admin.html /app/public/ COPY admin.html /app/public/
COPY offline.html /app/public/ COPY offline.html /app/public/
COPY style.css /app/public/ COPY style.css /app/public/
COPY app.js /app/public/ COPY app.js /app/public/
COPY auth.js /app/public/
COPY favorites.js /app/public/
COPY admin.js /app/public/ COPY admin.js /app/public/
COPY services.json /app/public/ COPY services.json /app/public/
COPY manifest.webmanifest /app/public/ COPY manifest.webmanifest /app/public/
@@ -17,6 +20,9 @@ COPY icons/ /app/public/icons/
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=80 ENV PORT=80
ENV FAVORITES_FILE=/app/data/favorites.json
RUN mkdir -p /app/data
EXPOSE 80 EXPOSE 80

View File

@@ -12,7 +12,7 @@
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png"> <link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
<link rel="stylesheet" href="/style.css?v=27"> <link rel="stylesheet" href="/style.css?v=28">
</head> </head>
<body> <body>
<header class="shell hero admin-hero"> <header class="shell hero admin-hero">

166
app.js
View File

@@ -16,6 +16,7 @@ const icons = {
let cards = []; let cards = [];
let sections = []; let sections = [];
let config = null; let config = null;
let favorites = [];
let activeFilter = 'all'; let activeFilter = 'all';
let categoryById = new Map(); let categoryById = new Map();
@@ -26,11 +27,25 @@ const onlineCount = document.querySelector('#onlineCount');
const serviceCount = document.querySelector('#serviceCount'); const serviceCount = document.querySelector('#serviceCount');
const lastChecked = document.querySelector('#lastChecked'); const lastChecked = document.querySelector('#lastChecked');
const dashboardSections = document.querySelector('#dashboardSections'); const dashboardSections = document.querySelector('#dashboardSections');
const favoritesSections = document.querySelector('#favoritesSections');
const loginButton = document.querySelector('#dashboardLoginButton');
const logoutButton = document.querySelector('#dashboardLogoutButton');
const favoritesLink = document.querySelector('#favoritesLink');
const userBadge = document.querySelector('#dashboardUserBadge');
function normalize(value) { function normalize(value) {
return String(value || '').toLowerCase().trim(); return String(value || '').toLowerCase().trim();
} }
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function getLocalConfig() { function getLocalConfig() {
try { try {
const raw = window.localStorage.getItem(LOCAL_CONFIG_KEY); const raw = window.localStorage.getItem(LOCAL_CONFIG_KEY);
@@ -181,6 +196,135 @@ function createServiceCard(service) {
return card; return card;
} }
function createFavoriteCard(favorite) {
const card = document.createElement('a');
card.href = favorite.url;
card.target = '_blank';
card.rel = 'noopener';
card.className = 'card favorite-card';
card.style.setProperty('--accent', favorite.color || '#67e8f9');
const safeLogo = favorite.logoUrl ? escapeHtml(favorite.logoUrl) : '';
const safeName = escapeHtml(favorite.name);
const safeDescription = escapeHtml(favorite.description || favorite.group || 'Favorit');
const iconSvg = icons[favorite.icon] || icons.folder;
card.innerHTML = `
<div class="card-icon favorite-icon" style="--accent: ${favorite.color || '#67e8f9'};">
${safeLogo
? `<img src="${safeLogo}" alt="" loading="lazy">`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${iconSvg}</svg>`}
</div>
<div class="card-content">
<span class="card-category">${escapeHtml(favorite.group || 'Favorit')}</span>
<h3>${safeName}</h3>
<p>${safeDescription}</p>
</div>
<span class="card-domain">${escapeHtml(new URL(favorite.url).hostname)}</span>
`;
return card;
}
function renderFavorites() {
if (!favoritesSections) return;
if (!MischLabsAuth.isAuthenticated()) {
favoritesSections.innerHTML = `
<section class="section favorites-section">
<div class="section-heading">
<div>
<p class="section-kicker">Persoenlich</p>
<h2>Deine Favoriten</h2>
</div>
</div>
<div class="favorites-empty">
<p>Melde dich mit SSO an, um eigene Kacheln und Weblinks zu speichern.</p>
<button class="ghost-button mini" id="favoritesLoginCallout" type="button">Mit SSO anmelden</button>
</div>
</section>
`;
document.querySelector('#favoritesLoginCallout')?.addEventListener('click', () => MischLabsAuth.login());
return;
}
if (!favorites.length) {
favoritesSections.innerHTML = `
<section class="section favorites-section">
<div class="section-heading">
<div>
<p class="section-kicker">Persoenlich</p>
<h2>Deine Favoriten</h2>
</div>
<a class="ghost-button mini" href="/favorites.html">Favoriten erstellen</a>
</div>
<div class="favorites-empty">
<p>Noch keine Favoriten. Lege eigene Kacheln fuer YouTube, Notion, Tools oder beliebige Links an.</p>
</div>
</section>
`;
return;
}
const groups = new Map();
favorites.forEach((favorite) => {
const group = favorite.group || 'Favoriten';
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(favorite);
});
favoritesSections.innerHTML = `
<section class="section favorites-section">
<div class="section-heading">
<div>
<p class="section-kicker">Persoenlich</p>
<h2>Deine Favoriten</h2>
</div>
<a class="ghost-button mini" href="/favorites.html">Bearbeiten</a>
</div>
<div class="favorite-groups"></div>
</section>
`;
const container = favoritesSections.querySelector('.favorite-groups');
[...groups.entries()].forEach(([group, items]) => {
const section = document.createElement('section');
section.className = 'favorite-group';
section.innerHTML = `
<div class="favorite-group-heading">
<h3>${escapeHtml(group)}</h3>
<span>${items.length} ${items.length === 1 ? 'Link' : 'Links'}</span>
</div>
<div class="grid favorite-grid"></div>
`;
const grid = section.querySelector('.grid');
items.forEach((favorite) => grid.appendChild(createFavoriteCard(favorite)));
container.appendChild(section);
});
}
async function loadFavorites() {
if (!MischLabsAuth.isAuthenticated()) {
favorites = [];
renderFavorites();
return;
}
try {
const response = await fetch('/api/favorites', {
cache: 'no-store',
headers: MischLabsAuth.authHeader()
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
} catch {
favorites = [];
}
renderFavorites();
}
function renderDashboard(nextConfig) { function renderDashboard(nextConfig) {
dashboardSections.innerHTML = ''; dashboardSections.innerHTML = '';
serviceCount.textContent = String(nextConfig.services.length); serviceCount.textContent = String(nextConfig.services.length);
@@ -313,17 +457,37 @@ segments.forEach((segment) => {
searchInput.addEventListener('input', updateVisibility); searchInput.addEventListener('input', updateVisibility);
function updateAuthNav() {
const authenticated = MischLabsAuth.isAuthenticated();
if (loginButton) loginButton.hidden = authenticated;
if (logoutButton) logoutButton.hidden = !authenticated;
if (favoritesLink) favoritesLink.hidden = !authenticated;
if (userBadge) {
userBadge.hidden = !authenticated;
userBadge.textContent = authenticated ? MischLabsAuth.displayName() : '';
}
}
loginButton?.addEventListener('click', () => MischLabsAuth.login());
logoutButton?.addEventListener('click', () => MischLabsAuth.logout());
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {}); navigator.serviceWorker.register('/sw.js').catch(() => {});
}); });
} }
loadConfig() MischLabsAuth.handleCallback()
.catch((error) => {
lastChecked.textContent = error.message;
})
.then(() => loadConfig())
.then((nextConfig) => { .then((nextConfig) => {
config = nextConfig; config = nextConfig;
updateAuthNav();
renderDashboard(config); renderDashboard(config);
updateVisibility(); updateVisibility();
loadFavorites();
scheduleStatusRefresh(); scheduleStatusRefresh();
}) })
.catch(() => { .catch(() => {

200
auth.js Normal file
View File

@@ -0,0 +1,200 @@
const MischLabsAuth = (() => {
const TOKEN_KEY = 'mischlabs.dashboard.tokens';
const PKCE_KEY = 'mischlabs.dashboard.pkce';
const CONFIG_KEY = 'mischlabs.dashboard.authConfig';
const SCOPES = 'openid profile email';
let configPromise = null;
let cachedConfig = 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);
}
async function getConfig() {
if (cachedConfig) return cachedConfig;
if (!configPromise) {
configPromise = fetch('/api/auth/config', { cache: 'no-store' })
.then((response) => {
if (!response.ok) throw new Error(`Auth config returned ${response.status}`);
return response.json();
})
.then((config) => {
cachedConfig = config;
sessionStorage.setItem(CONFIG_KEY, JSON.stringify(config));
return config;
})
.catch((error) => {
const fallback = JSON.parse(sessionStorage.getItem(CONFIG_KEY) || 'null');
if (fallback) return fallback;
throw error;
});
}
return configPromise;
}
function redirectUri() {
const path = window.location.pathname === '/index.html' ? '/' : window.location.pathname;
return `${window.location.origin}${path}`;
}
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 tokens() {
try {
const raw = sessionStorage.getItem(TOKEN_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function storeTokens(nextTokens) {
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(nextTokens));
}
function clearTokens() {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(PKCE_KEY);
}
function isAuthenticated() {
const current = tokens();
if (!current?.id_token) return false;
try {
return Date.now() < decodeJwt(current.id_token).exp * 1000;
} catch {
return false;
}
}
function claims() {
const current = tokens();
if (!current?.id_token) return null;
try {
return decodeJwt(current.id_token);
} catch {
return null;
}
}
function authHeader() {
const current = tokens();
return current?.id_token ? { Authorization: `Bearer ${current.id_token}` } : {};
}
async function login() {
const config = await getConfig();
const verifier = randomString();
const challenge = await createChallenge(verifier);
const state = randomString(32);
const redirect = redirectUri();
sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, redirect }));
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirect,
response_type: 'code',
scope: config.scopes || SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
window.location.href = `${config.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) {
window.history.replaceState({}, document.title, redirectUri());
throw new Error(`SSO Fehler: ${error}`);
}
if (!code) return false;
const config = await getConfig();
const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}');
if (!stored.verifier || stored.state !== state) {
throw new Error('SSO Antwort konnte nicht verifiziert werden.');
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.clientId,
redirect_uri: stored.redirect || redirectUri(),
code,
code_verifier: stored.verifier
});
const response = await fetch(`${config.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}`);
}
storeTokens(await response.json());
sessionStorage.removeItem(PKCE_KEY);
window.history.replaceState({}, document.title, redirectUri());
return true;
}
async function logout() {
const config = await getConfig();
const idToken = tokens()?.id_token;
clearTokens();
const params = new URLSearchParams({
client_id: config.clientId,
post_logout_redirect_uri: redirectUri()
});
if (idToken) params.set('id_token_hint', idToken);
window.location.href = `${config.authority}/protocol/openid-connect/logout?${params}`;
}
function displayName() {
const currentClaims = claims();
return currentClaims?.name || currentClaims?.preferred_username || currentClaims?.email || 'Angemeldet';
}
return {
authHeader,
claims,
clearTokens,
displayName,
getConfig,
handleCallback,
isAuthenticated,
login,
logout
};
})();

View File

@@ -9,3 +9,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /volume1:/host/volume1:ro - /volume1:/host/volume1:ro
- /volume2:/host/volume2:ro - /volume2:/host/volume2:ro
- mischlabs_data:/app/data
volumes:
mischlabs_data:

73
favorites.html Normal file
View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MischLabs - Favoriten</title>
<meta name="description" content="Persoenliche MischLabs Favoriten verwalten.">
<meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.webmanifest?v=4">
<link rel="shortcut icon" href="/favicon.ico?v=4">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32-v4.png">
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
<link rel="stylesheet" href="/style.css?v=28">
</head>
<body>
<header class="shell hero admin-hero">
<div class="brand">
<img class="brand-mark" src="/icons/app-icon-source.png" alt="" width="72" height="72">
<div>
<p class="eyebrow">Persoenlich</p>
<h1>Favoriten</h1>
<p class="subtitle">Eigene Weblinks als Kacheln auf deinem MischLabs Dashboard.</p>
</div>
</div>
<div class="admin-actions">
<a class="ghost-button" href="/">Dashboard</a>
<button class="ghost-button" id="favoritesLoginButton" type="button">Mit SSO anmelden</button>
<button class="ghost-button" id="favoritesLogoutButton" type="button" hidden>Abmelden</button>
</div>
</header>
<main class="shell favorites-shell">
<section class="admin-panel" id="favoritesLoginPanel">
<p class="section-kicker">SSO</p>
<h2>Anmeldung erforderlich</h2>
<p class="admin-copy">Deine Favoriten werden pro Keycloak-Benutzer gespeichert.</p>
<div class="notice" id="favoritesMessage">Noch nicht angemeldet.</div>
</section>
<section class="admin-panel" id="favoritesPanel" hidden>
<div class="admin-panel-head">
<div>
<p class="section-kicker">Editor</p>
<h2>Favoriten verwalten</h2>
<p class="admin-copy">Lege Kacheln an, waehle Farbe und Logo, gruppiere sie und sortiere die Reihenfolge.</p>
</div>
<button class="ghost-button" id="addFavoriteButton" type="button">Favorit hinzufuegen</button>
</div>
<div class="notice" id="favoritesUser"></div>
<div class="favorite-editor-layout">
<div class="favorite-editor-list" id="favoriteEditor"></div>
<aside class="favorite-preview-panel">
<p class="section-kicker">Vorschau</p>
<div id="favoritePreview" class="grid favorite-preview-grid"></div>
</aside>
</div>
<div class="admin-actions-bar">
<button class="ghost-button" id="saveFavoritesButton" type="button">Speichern</button>
<button class="ghost-button" id="reloadFavoritesButton" type="button">Neu laden</button>
</div>
</section>
</main>
<script src="/auth.js?v=1" defer></script>
<script src="/favorites.js?v=1" defer></script>
</body>
</html>

239
favorites.js Normal file
View File

@@ -0,0 +1,239 @@
const favoriteIcons = {
star: '<polygon points="12 2 15 8.5 22 9.3 16.8 14 18.2 21 12 17.4 5.8 21 7.2 14 2 9.3 9 8.5 12 2"></polygon>',
link: '<path d="M10 13a5 5 0 0 0 7.1 0l2.8-2.8a5 5 0 0 0-7.1-7.1l-1.6 1.6"></path><path d="M14 11a5 5 0 0 0-7.1 0l-2.8 2.8a5 5 0 0 0 7.1 7.1l1.6-1.6"></path>',
play: '<circle cx="12" cy="12" r="10"></circle><path d="m10 8 6 4-6 4z"></path>',
note: '<path d="M4 3h11l5 5v13H4z"></path><path d="M14 3v6h6"></path><path d="M8 13h8"></path><path d="M8 17h6"></path>',
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>',
code: '<path d="m16 18 6-6-6-6"></path><path d="m8 6-6 6 6 6"></path>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m3 7 9 6 9-6"></path>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4"></path><path d="M8 2v4"></path><path d="M3 10h18"></path>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="m21 15-5-5L5 21"></path>',
tool: '<path d="M14.7 6.3a4 4 0 0 0-5 5L3 18v3h3l6.7-6.7a4 4 0 0 0 5-5l-2.8 2.8-2-2z"></path>'
};
const iconLabels = {
star: 'Stern',
link: 'Link',
play: 'Video',
note: 'Notiz',
book: 'Buch',
code: 'Code',
mail: 'Mail',
calendar: 'Kalender',
image: 'Bild',
tool: 'Tool'
};
const loginButton = document.querySelector('#favoritesLoginButton');
const logoutButton = document.querySelector('#favoritesLogoutButton');
const loginPanel = document.querySelector('#favoritesLoginPanel');
const panel = document.querySelector('#favoritesPanel');
const message = document.querySelector('#favoritesMessage');
const userBox = document.querySelector('#favoritesUser');
const editor = document.querySelector('#favoriteEditor');
const preview = document.querySelector('#favoritePreview');
const addButton = document.querySelector('#addFavoriteButton');
const saveButton = document.querySelector('#saveFavoritesButton');
const reloadButton = document.querySelector('#reloadFavoritesButton');
let favorites = [];
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function createDefaultFavorite() {
return {
id: crypto.randomUUID(),
name: 'Neuer Link',
url: 'https://',
group: 'Favoriten',
description: '',
color: '#67e8f9',
icon: 'star',
logoUrl: '',
order: favorites.length
};
}
function iconOptions(selected) {
return Object.keys(favoriteIcons).map((icon) => (
`<option value="${icon}" ${icon === selected ? 'selected' : ''}>${iconLabels[icon]}</option>`
)).join('');
}
function syncFromInputs() {
editor.querySelectorAll('.favorite-edit-card').forEach((row) => {
const favorite = favorites[Number(row.dataset.index)];
if (!favorite) return;
row.querySelectorAll('[data-field]').forEach((input) => {
favorite[input.dataset.field] = input.value;
});
});
}
function moveFavorite(index, direction) {
const next = index + direction;
if (next < 0 || next >= favorites.length) return;
syncFromInputs();
[favorites[index], favorites[next]] = [favorites[next], favorites[index]];
favorites = favorites.map((favorite, order) => ({ ...favorite, order }));
render();
}
function removeFavorite(index) {
syncFromInputs();
favorites.splice(index, 1);
favorites = favorites.map((favorite, order) => ({ ...favorite, order }));
render();
}
function renderPreviewCard(favorite) {
const color = /^#[0-9a-fA-F]{6}$/.test(favorite.color) ? favorite.color : '#67e8f9';
const iconSvg = favoriteIcons[favorite.icon] || favoriteIcons.star;
let host = '';
try {
host = new URL(favorite.url).hostname;
} catch {
host = 'ungueltige-url';
}
return `
<article class="card favorite-card" style="--accent: ${color};">
<div class="card-icon favorite-icon" style="--accent: ${color};">
${favorite.logoUrl
? `<img src="${escapeHtml(favorite.logoUrl)}" alt="" loading="lazy">`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${iconSvg}</svg>`}
</div>
<div class="card-content">
<span class="card-category">${escapeHtml(favorite.group || 'Favorit')}</span>
<h3>${escapeHtml(favorite.name)}</h3>
<p>${escapeHtml(favorite.description || 'Eigener Weblink')}</p>
</div>
<span class="card-domain">${escapeHtml(host)}</span>
</article>
`;
}
function renderPreview() {
preview.innerHTML = favorites.map(renderPreviewCard).join('') || '<div class="favorites-empty">Die Vorschau erscheint hier.</div>';
}
function render() {
editor.innerHTML = favorites.map((favorite, index) => `
<article class="favorite-edit-card" data-index="${index}">
<div class="favorite-edit-head">
<strong>${escapeHtml(favorite.name || 'Neuer Link')}</strong>
<div class="favorite-order-actions">
<button class="ghost-button mini" data-action="up" type="button" ${index === 0 ? 'disabled' : ''}>Hoch</button>
<button class="ghost-button mini" data-action="down" type="button" ${index === favorites.length - 1 ? 'disabled' : ''}>Runter</button>
<button class="ghost-button mini danger-button" data-action="remove" type="button">Entfernen</button>
</div>
</div>
<div class="favorite-form-grid">
<label>Beschriftung<input data-field="name" value="${escapeHtml(favorite.name)}" placeholder="YouTube"></label>
<label>Link<input data-field="url" value="${escapeHtml(favorite.url)}" placeholder="https://youtube.com"></label>
<label>Gruppe<input data-field="group" value="${escapeHtml(favorite.group)}" placeholder="Privat"></label>
<label>Beschreibung<input data-field="description" value="${escapeHtml(favorite.description)}" placeholder="Optionaler Untertitel"></label>
<label>Farbe<input data-field="color" type="color" value="${escapeHtml(favorite.color || '#67e8f9')}"></label>
<label>Icon<select data-field="icon">${iconOptions(favorite.icon || 'star')}</select></label>
<label class="favorite-form-wide">Logo URL<input data-field="logoUrl" value="${escapeHtml(favorite.logoUrl)}" placeholder="https://.../logo.png"></label>
</div>
</article>
`).join('') || '<div class="favorites-empty">Noch keine Favoriten angelegt.</div>';
renderPreview();
editor.querySelectorAll('[data-field]').forEach((input) => {
input.addEventListener('input', () => {
syncFromInputs();
renderPreview();
});
});
editor.querySelectorAll('[data-action]').forEach((button) => {
button.addEventListener('click', () => {
const index = Number(button.closest('.favorite-edit-card').dataset.index);
if (button.dataset.action === 'up') moveFavorite(index, -1);
if (button.dataset.action === 'down') moveFavorite(index, 1);
if (button.dataset.action === 'remove') removeFavorite(index);
});
});
}
async function loadFavorites() {
const response = await fetch('/api/favorites', {
cache: 'no-store',
headers: MischLabsAuth.authHeader()
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
userBox.textContent = `Angemeldet als ${payload.user?.name || MischLabsAuth.displayName()}`;
render();
}
async function saveFavorites() {
syncFromInputs();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...MischLabsAuth.authHeader()
},
body: JSON.stringify({ favorites })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
favorites = payload.favorites || [];
render();
userBox.textContent = 'Favoriten gespeichert.';
}
function updateAuthUi() {
const authenticated = MischLabsAuth.isAuthenticated();
loginButton.hidden = authenticated;
logoutButton.hidden = !authenticated;
loginPanel.hidden = authenticated;
panel.hidden = !authenticated;
}
async function boot() {
try {
await MischLabsAuth.handleCallback();
} catch (error) {
message.textContent = error.message;
}
updateAuthUi();
if (MischLabsAuth.isAuthenticated()) {
await loadFavorites();
}
}
loginButton.addEventListener('click', () => MischLabsAuth.login());
logoutButton.addEventListener('click', () => MischLabsAuth.logout());
addButton.addEventListener('click', () => {
syncFromInputs();
favorites.push(createDefaultFavorite());
render();
});
saveButton.addEventListener('click', () => {
saveFavorites().catch((error) => {
userBox.textContent = `Speichern fehlgeschlagen: ${error.message}`;
});
});
reloadButton.addEventListener('click', () => {
loadFavorites().catch((error) => {
userBox.textContent = `Laden fehlgeschlagen: ${error.message}`;
});
});
boot().catch((error) => {
message.textContent = `Favoritenseite konnte nicht starten: ${error.message}`;
});

View File

@@ -15,7 +15,7 @@
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png"> <link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
<link rel="stylesheet" href="style.css?v=27"> <link rel="stylesheet" href="style.css?v=28">
</head> </head>
<body> <body>
<header class="shell hero"> <header class="shell hero">
@@ -42,6 +42,13 @@
<span class="metric-label">Installierbar</span> <span class="metric-label">Installierbar</span>
</div> </div>
</div> </div>
<nav class="dashboard-actions" aria-label="Dashboard Aktionen">
<span class="user-badge" id="dashboardUserBadge" hidden></span>
<a class="ghost-button mini" id="favoritesLink" href="/favorites.html" hidden>Favoriten</a>
<button class="ghost-button mini" id="dashboardLoginButton" type="button">SSO Login</button>
<button class="ghost-button mini" id="dashboardLogoutButton" type="button" hidden>Abmelden</button>
</nav>
</header> </header>
<main class="shell"> <main class="shell">
@@ -66,6 +73,7 @@
</section> </section>
<div id="dashboardSections"></div> <div id="dashboardSections"></div>
<div id="favoritesSections"></div>
<p class="empty-state" id="emptyState" hidden>Kein Dienst passt zur Suche.</p> <p class="empty-state" id="emptyState" hidden>Kein Dienst passt zur Suche.</p>
</main> </main>
@@ -119,6 +127,7 @@
</svg> </svg>
</div> </div>
<script src="/app.js?v=31" defer></script> <script src="/auth.js?v=1" defer></script>
<script src="/app.js?v=32" defer></script>
</body> </body>
</html> </html>

View File

@@ -11,7 +11,7 @@
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png"> <link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
<link rel="stylesheet" href="/style.css?v=13"> <link rel="stylesheet" href="/style.css?v=28">
</head> </head>
<body> <body>
<main class="shell hero"> <main class="shell hero">

162
server.js
View File

@@ -16,6 +16,8 @@ const WATCHTOWER_CONFIG_PATH = process.env.WATCHTOWER_CONFIG_PATH || '/volume2/d
const WATCHTOWER_LOG_TAIL = Number(process.env.WATCHTOWER_LOG_TAIL || 1200); const WATCHTOWER_LOG_TAIL = Number(process.env.WATCHTOWER_LOG_TAIL || 1200);
const AUTHORITY = process.env.AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs'; const AUTHORITY = process.env.AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs';
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'mischlabs-admin'; const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'mischlabs-admin';
const DASHBOARD_CLIENT_ID = process.env.DASHBOARD_CLIENT_ID || ADMIN_CLIENT_ID;
const FAVORITES_FILE = process.env.FAVORITES_FILE || path.join(__dirname, 'data', 'favorites.json');
const ADMIN_USERS = (process.env.ADMIN_USERS || 'mrdiderot').split(',').map((user) => user.trim().toLowerCase()).filter(Boolean); const ADMIN_USERS = (process.env.ADMIN_USERS || 'mrdiderot').split(',').map((user) => user.trim().toLowerCase()).filter(Boolean);
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'mail.misch@pm.me').split(',').map((email) => email.trim().toLowerCase()).filter(Boolean); const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'mail.misch@pm.me').split(',').map((email) => email.trim().toLowerCase()).filter(Boolean);
const RESTART_BLOCKLIST = new Set((process.env.RESTART_BLOCKLIST || [ const RESTART_BLOCKLIST = new Set((process.env.RESTART_BLOCKLIST || [
@@ -156,6 +158,14 @@ function authError(message) {
} }
async function verifyAdminRequest(req) { async function verifyAdminRequest(req) {
return verifyOidcRequest(req, { clientId: ADMIN_CLIENT_ID, requireAdmin: true });
}
async function verifyUserRequest(req) {
return verifyOidcRequest(req, { clientId: DASHBOARD_CLIENT_ID, requireAdmin: false });
}
async function verifyOidcRequest(req, { clientId, requireAdmin }) {
const token = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]; const token = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) throw authError('missing_token'); if (!token) throw authError('missing_token');
@@ -176,11 +186,11 @@ async function verifyAdminRequest(req) {
if (claims.exp * 1000 < Date.now()) throw authError('expired_token'); if (claims.exp * 1000 < Date.now()) throw authError('expired_token');
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!audience.includes(ADMIN_CLIENT_ID) && claims.azp !== ADMIN_CLIENT_ID) throw authError('invalid_audience'); if (!audience.includes(clientId) && claims.azp !== clientId) throw authError('invalid_audience');
const username = String(claims.preferred_username || '').toLowerCase(); const username = String(claims.preferred_username || '').toLowerCase();
const email = String(claims.email || '').toLowerCase(); const email = String(claims.email || '').toLowerCase();
if (!ADMIN_USERS.includes(username) && !ADMIN_EMAILS.includes(email)) throw authError('not_allowed'); if (requireAdmin && !ADMIN_USERS.includes(username) && !ADMIN_EMAILS.includes(email)) throw authError('not_allowed');
const jwks = await getJwks(); const jwks = await getJwks();
const jwk = jwks.keys?.find((key) => key.kid === header.kid); const jwk = jwks.keys?.find((key) => key.kid === header.kid);
@@ -196,6 +206,109 @@ async function verifyAdminRequest(req) {
return claims; return claims;
} }
function readRequestBody(req, maxBytes = 128 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on('data', (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(Object.assign(new Error('payload_too_large'), { statusCode: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function userKeyFromClaims(claims) {
return String(claims.sub || claims.preferred_username || claims.email || '').replace(/[^a-zA-Z0-9_.@-]/g, '_');
}
function publicUserFromClaims(claims) {
return {
id: claims.sub,
username: claims.preferred_username || null,
email: claims.email || null,
name: claims.name || claims.preferred_username || claims.email || 'Nutzer'
};
}
async function readFavoritesStore() {
try {
const raw = await fsp.readFile(FAVORITES_FILE, 'utf8');
return JSON.parse(raw);
} catch (error) {
if (error.code === 'ENOENT') return {};
throw error;
}
}
async function writeFavoritesStore(store) {
await fsp.mkdir(path.dirname(FAVORITES_FILE), { recursive: true });
const tmp = `${FAVORITES_FILE}.${process.pid}.tmp`;
await fsp.writeFile(tmp, JSON.stringify(store, null, 2), 'utf8');
await fsp.rename(tmp, FAVORITES_FILE);
}
function cleanText(value, max = 80) {
return String(value || '').trim().replace(/\s+/g, ' ').slice(0, max);
}
function cleanColor(value) {
const color = String(value || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#67e8f9';
}
function cleanUrl(value) {
const url = new URL(String(value || '').trim());
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Ungueltige URL.');
return url.href;
}
function sanitizeFavorites(input) {
if (!Array.isArray(input)) throw new Error('Favoriten muessen ein Array sein.');
return input.slice(0, 80).map((item, index) => {
const name = cleanText(item.name, 64);
if (!name) throw new Error('Jeder Favorit braucht eine Beschriftung.');
return {
id: cleanText(item.id, 80) || crypto.randomUUID(),
name,
url: cleanUrl(item.url),
group: cleanText(item.group || 'Favoriten', 40) || 'Favoriten',
description: cleanText(item.description, 120),
color: cleanColor(item.color),
icon: cleanText(item.icon || 'star', 32),
logoUrl: item.logoUrl ? cleanUrl(item.logoUrl) : '',
order: Number.isFinite(Number(item.order)) ? Number(item.order) : index
};
}).sort((a, b) => a.order - b.order).map((item, index) => ({ ...item, order: index }));
}
async function getUserFavorites(claims) {
const store = await readFavoritesStore();
const key = userKeyFromClaims(claims);
return Array.isArray(store[key]?.favorites) ? store[key].favorites : [];
}
async function saveUserFavorites(claims, favorites) {
const store = await readFavoritesStore();
const key = userKeyFromClaims(claims);
store[key] = {
user: publicUserFromClaims(claims),
updatedAt: new Date().toISOString(),
favorites: sanitizeFavorites(favorites)
};
await writeFavoritesStore(store);
return store[key].favorites;
}
function normalizeContainerName(name) { function normalizeContainerName(name) {
return String(name || '').replace(/^\//, '').trim(); return String(name || '').replace(/^\//, '').trim();
} }
@@ -946,6 +1059,51 @@ async function serveStatic(req, res) {
} }
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
if (req.url?.startsWith('/api/auth/config')) {
if (req.method !== 'GET') {
json(res, 405, { error: 'Method not allowed' });
return;
}
json(res, 200, {
authority: AUTHORITY,
clientId: DASHBOARD_CLIENT_ID,
scopes: 'openid profile email'
});
return;
}
if (req.url?.startsWith('/api/favorites')) {
try {
const claims = await verifyUserRequest(req);
if (req.method === 'GET') {
json(res, 200, {
user: publicUserFromClaims(claims),
favorites: await getUserFavorites(claims)
});
return;
}
if (req.method === 'PUT') {
const body = await readRequestBody(req);
const payload = JSON.parse(body || '{}');
const favorites = await saveUserFavorites(claims, payload.favorites);
json(res, 200, {
user: publicUserFromClaims(claims),
favorites
});
return;
}
json(res, 405, { error: 'Method not allowed' });
} catch (error) {
const status = error.statusCode || (['missing_token', 'invalid_token', 'expired_token', 'not_allowed', 'invalid_audience'].includes(error.message) ? 401 : 500);
json(res, status, { error: error.message });
}
return;
}
if (req.url?.startsWith('/api/status')) { if (req.url?.startsWith('/api/status')) {
try { try {
json(res, 200, await statusPayload()); json(res, 200, await statusPayload());

173
style.css
View File

@@ -121,6 +121,27 @@ h1 {
gap: 10px; gap: 10px;
} }
.dashboard-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-top: -10px;
}
.user-badge {
min-height: 34px;
display: inline-flex;
align-items: center;
padding: 0 12px;
color: #c4f1ff;
background: rgba(17, 22, 36, 0.72);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.82rem;
}
.metric { .metric {
min-width: 88px; min-width: 88px;
padding: 13px 14px; padding: 13px 14px;
@@ -328,6 +349,13 @@ main {
height: 23px; height: 23px;
} }
.favorite-icon img {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: cover;
}
.card-content { .card-content {
min-width: 0; min-width: 0;
} }
@@ -413,6 +441,134 @@ main {
font-size: 0.8rem; font-size: 0.8rem;
} }
.favorites-section {
margin-top: 28px;
padding-top: 6px;
}
.favorites-empty {
padding: 16px;
color: var(--muted);
background: rgba(17, 22, 36, 0.56);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.favorites-empty p {
margin-bottom: 12px;
line-height: 1.45;
}
.favorite-groups {
display: grid;
gap: 18px;
}
.favorite-group {
display: grid;
gap: 10px;
}
.favorite-group-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.favorite-group-heading h3 {
color: #fff;
font-size: 0.98rem;
}
.favorite-group-heading span {
color: var(--dim);
font-size: 0.78rem;
}
.favorite-editor-layout {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
gap: 14px;
align-items: start;
margin-top: 14px;
}
.favorite-editor-list {
display: grid;
gap: 12px;
}
.favorite-edit-card {
padding: 14px;
background: rgba(7, 9, 20, 0.42);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.favorite-edit-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.favorite-order-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.favorite-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.favorite-form-grid label {
display: grid;
gap: 6px;
color: var(--dim);
font-size: 0.76rem;
}
.favorite-form-grid input,
.favorite-form-grid select {
width: 100%;
min-height: 38px;
padding: 0 10px;
color: var(--text);
background: rgba(17, 22, 36, 0.92);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
}
.favorite-form-grid input[type="color"] {
padding: 3px;
}
.favorite-form-wide {
grid-column: 1 / -1;
}
.favorite-preview-panel {
position: sticky;
top: 72px;
display: grid;
gap: 12px;
padding: 14px;
background: rgba(7, 9, 20, 0.42);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.favorite-preview-grid {
grid-template-columns: 1fr;
}
.admin-hero { .admin-hero {
align-items: center; align-items: center;
} }
@@ -752,10 +908,23 @@ main {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.favorite-editor-layout {
grid-template-columns: 1fr;
}
.favorite-preview-panel {
position: static;
}
.hero-meta { .hero-meta {
width: 100%; width: 100%;
} }
.dashboard-actions {
justify-content: flex-start;
margin-top: 0;
}
.control-bar { .control-bar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -811,6 +980,10 @@ main {
padding: 14px; padding: 14px;
} }
.favorite-form-grid {
grid-template-columns: 1fr;
}
.section-heading { .section-heading {
align-items: start; align-items: start;
} }

9
sw.js
View File

@@ -1,10 +1,13 @@
const CACHE_NAME = 'mischlabs-pwa-v14'; const CACHE_NAME = 'mischlabs-pwa-v15';
const APP_SHELL = [ const APP_SHELL = [
'/', '/',
'/index.html', '/index.html',
'/favorites.html',
'/offline.html', '/offline.html',
'/style.css?v=27', '/style.css?v=28',
'/app.js?v=31', '/auth.js?v=1',
'/app.js?v=32',
'/favorites.js?v=1',
'/services.json?v=1', '/services.json?v=1',
'/manifest.webmanifest?v=4', '/manifest.webmanifest?v=4',
'/favicon.ico?v=4', '/favicon.ico?v=4',