Add SSO admin console for dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
This commit is contained in:
318
admin.js
Normal file
318
admin.js
Normal file
@@ -0,0 +1,318 @@
|
||||
const AUTHORITY = 'https://auth.mischlabs.de/realms/mischlabs';
|
||||
const CLIENT_ID = 'mischlabs-admin';
|
||||
const REDIRECT_URI = `${window.location.origin}/admin.html`;
|
||||
const SCOPES = 'openid profile email';
|
||||
const CONFIG_URL = '/services.json?v=1';
|
||||
const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
|
||||
const TOKEN_KEY = 'mischlabs.admin.tokens';
|
||||
const PKCE_KEY = 'mischlabs.admin.pkce';
|
||||
|
||||
const loginButton = document.querySelector('#loginButton');
|
||||
const logoutButton = document.querySelector('#logoutButton');
|
||||
const loginPanel = document.querySelector('#loginPanel');
|
||||
const adminPanel = document.querySelector('#adminPanel');
|
||||
const loginMessage = document.querySelector('#loginMessage');
|
||||
const adminUser = document.querySelector('#adminUser');
|
||||
const serviceEditor = document.querySelector('#serviceEditor');
|
||||
const checkAllButton = document.querySelector('#checkAllButton');
|
||||
const saveLocalButton = document.querySelector('#saveLocalButton');
|
||||
const resetLocalButton = document.querySelector('#resetLocalButton');
|
||||
const downloadConfigButton = document.querySelector('#downloadConfigButton');
|
||||
|
||||
let config = null;
|
||||
let tokenSet = null;
|
||||
|
||||
function base64UrlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function randomString(length = 64) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return [...bytes].map((byte) => chars[byte % chars.length]).join('');
|
||||
}
|
||||
|
||||
async function createChallenge(verifier) {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const json = decodeURIComponent(atob(payload).split('').map((char) => (
|
||||
`%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`
|
||||
)).join(''));
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function getStoredTokens() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeTokens(tokens) {
|
||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(PKCE_KEY);
|
||||
}
|
||||
|
||||
function isTokenValid(tokens) {
|
||||
if (!tokens?.id_token) return false;
|
||||
try {
|
||||
const claims = decodeJwt(tokens.id_token);
|
||||
return Date.now() < claims.exp * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const verifier = randomString();
|
||||
const challenge = await createChallenge(verifier);
|
||||
const state = randomString(32);
|
||||
sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state }));
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
window.location.href = `${AUTHORITY}/protocol/openid-connect/auth?${params}`;
|
||||
}
|
||||
|
||||
async function handleCallback() {
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
loginMessage.textContent = `Keycloak Fehler: ${error}`;
|
||||
window.history.replaceState({}, document.title, REDIRECT_URI);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) return;
|
||||
|
||||
const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}');
|
||||
if (!stored.verifier || stored.state !== state) {
|
||||
loginMessage.textContent = 'SSO Antwort konnte nicht verifiziert werden.';
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code,
|
||||
code_verifier: stored.verifier
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${AUTHORITY}/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token endpoint returned ${response.status}`);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
storeTokens(tokens);
|
||||
window.history.replaceState({}, document.title, REDIRECT_URI);
|
||||
} catch (error) {
|
||||
loginMessage.textContent = `Token-Austausch fehlgeschlagen: ${error.message}. Keycloak Client/Web Origins pruefen.`;
|
||||
}
|
||||
}
|
||||
|
||||
function authorize(tokens) {
|
||||
const claims = decodeJwt(tokens.id_token);
|
||||
const username = String(claims.preferred_username || '').toLowerCase();
|
||||
const email = String(claims.email || '').toLowerCase();
|
||||
const allowed = username === 'mrdiderot' || email === 'mail.misch@pm.me';
|
||||
|
||||
if (!allowed) {
|
||||
loginMessage.textContent = `Angemeldet als ${claims.preferred_username || claims.email}, aber nicht fuer diese Adminseite freigeschaltet.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const local = localStorage.getItem(LOCAL_CONFIG_KEY);
|
||||
if (local) return JSON.parse(local);
|
||||
|
||||
const response = await fetch(CONFIG_URL, { cache: 'no-store' });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function categoryOptions(selected) {
|
||||
return config.categories.map((category) => (
|
||||
`<option value="${category.id}" ${category.id === selected ? 'selected' : ''}>${category.label}</option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
serviceEditor.innerHTML = config.services.map((service, index) => `
|
||||
<article class="admin-row" data-index="${index}">
|
||||
<div>
|
||||
<strong>${service.name}</strong>
|
||||
<span>${service.domain}</span>
|
||||
</div>
|
||||
<label>
|
||||
Kategorie
|
||||
<select data-field="category">${categoryOptions(service.category)}</select>
|
||||
</label>
|
||||
<label>
|
||||
URL
|
||||
<input data-field="url" value="${service.url}">
|
||||
</label>
|
||||
<button class="ghost-button check-one" type="button">Pruefen</button>
|
||||
<div class="diagnostic">Noch nicht geprueft.</div>
|
||||
</article>
|
||||
`).join('');
|
||||
|
||||
serviceEditor.querySelectorAll('select,input').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
const row = input.closest('.admin-row');
|
||||
const service = config.services[Number(row.dataset.index)];
|
||||
service[input.dataset.field] = input.value;
|
||||
});
|
||||
});
|
||||
|
||||
serviceEditor.querySelectorAll('.check-one').forEach((button) => {
|
||||
button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
|
||||
});
|
||||
}
|
||||
|
||||
async function probe(url) {
|
||||
const started = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'no-cors',
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
ms: Math.round(performance.now() - started),
|
||||
detail: response.type === 'opaque'
|
||||
? 'Erreichbar. HTTP-Details wegen Browser-CORS nicht lesbar.'
|
||||
: `HTTP ${response.status}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
ms: Math.round(performance.now() - started),
|
||||
detail: error.name === 'AbortError' ? 'Timeout nach 6 Sekunden.' : error.message
|
||||
};
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRow(row) {
|
||||
const service = config.services[Number(row.dataset.index)];
|
||||
const diagnostic = row.querySelector('.diagnostic');
|
||||
diagnostic.textContent = 'Pruefe...';
|
||||
row.classList.remove('is-online', 'is-offline');
|
||||
|
||||
const result = await probe(service.url);
|
||||
row.classList.toggle('is-online', result.ok);
|
||||
row.classList.toggle('is-offline', !result.ok);
|
||||
diagnostic.textContent = `${result.ok ? 'Online' : 'Fehler'} - ${result.ms} ms - ${result.detail}`;
|
||||
}
|
||||
|
||||
function saveLocal() {
|
||||
localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'services.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
await handleCallback();
|
||||
tokenSet = getStoredTokens();
|
||||
|
||||
if (!isTokenValid(tokenSet)) {
|
||||
loginPanel.hidden = false;
|
||||
adminPanel.hidden = true;
|
||||
loginButton.hidden = false;
|
||||
logoutButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const claims = authorize(tokenSet);
|
||||
if (!claims) return;
|
||||
|
||||
config = await loadConfig();
|
||||
loginPanel.hidden = true;
|
||||
adminPanel.hidden = false;
|
||||
loginButton.hidden = true;
|
||||
logoutButton.hidden = false;
|
||||
adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`;
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
loginButton.addEventListener('click', login);
|
||||
logoutButton.addEventListener('click', () => {
|
||||
const idToken = tokenSet?.id_token;
|
||||
clearTokens();
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
post_logout_redirect_uri: REDIRECT_URI
|
||||
});
|
||||
if (idToken) params.set('id_token_hint', idToken);
|
||||
window.location.href = `${AUTHORITY}/protocol/openid-connect/logout?${params}`;
|
||||
});
|
||||
|
||||
checkAllButton.addEventListener('click', () => {
|
||||
[...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row));
|
||||
});
|
||||
|
||||
saveLocalButton.addEventListener('click', () => {
|
||||
saveLocal();
|
||||
window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.');
|
||||
});
|
||||
|
||||
resetLocalButton.addEventListener('click', () => {
|
||||
localStorage.removeItem(LOCAL_CONFIG_KEY);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
downloadConfigButton.addEventListener('click', downloadConfig);
|
||||
|
||||
boot().catch((error) => {
|
||||
loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user