feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
166
app.js
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
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
200
auth.js
Normal 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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
73
favorites.html
Normal 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
239
favorites.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
});
|
||||||
13
index.html
13
index.html
@@ -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>
|
||||||
|
|||||||
@@ -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
162
server.js
@@ -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
173
style.css
@@ -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
9
sw.js
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user