Entscheidungshilfe für Onboarding Verfahren zur Keycloak-Registrierung und Anmeldung für Nostr

This commit is contained in:
Jörg Lohrer 2025-11-03 14:37:38 +00:00
parent 777a435942
commit 40288905b0

View file

@ -0,0 +1,795 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>3-Achsen-Dreiecks-Slider Nostr & Keycloak</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem 1rem;
max-width: 900px;
margin: 0 auto;
background: #FFFFFF;
color: #3A4F66;
}
h1 {
font-size: 1.3rem;
text-align: center;
color: #FAAC00;
font-weight: 700;
}
h1 small {
font-size: 0.9rem;
color: #3A4F66;
font-weight: 500;
}
h2 {
color: #192A3D;
font-weight: 600;
}
h3, h4, h5, h6 {
color: #3A4F66;
font-weight: 600;
}
a {
color: #3F40F7;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: #00A8CC;
}
::selection {
background-color: #1559ED;
color: #FFFFFF;
}
.triangle-wrapper {
position: relative;
width: 320px;
height: 280px;
min-width: 280px;
background: #FFFFFF;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(21, 89, 237, 0.1);
border: 2px solid #E1E8ED;
padding: 1rem;
}
svg {
width: 100%;
height: 100%;
display: block;
}
.handle {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #3F40F7 0%, #1559ED 100%);
border: 3px solid white;
box-shadow: 0 2px 8px rgba(63, 64, 247, 0.4);
transform: translate(-50%, -50%);
cursor: grab;
transition: box-shadow 0.2s ease;
z-index: 10;
}
.handle:hover {
box-shadow: 0 4px 16px rgba(63, 64, 247, 0.6);
}
.handle:active {
cursor: grabbing;
}
/* Größere Hit-Area für Touch */
.handle::before {
content: '';
position: absolute;
inset: -10px;
border-radius: 50%;
}
.values {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
justify-content: center;
background: #FFFFFF;
padding: 1.5rem;
border-radius: 8px;
border: 2px solid #E1E8ED;
box-shadow: 0 2px 8px rgba(58, 79, 102, 0.05);
width: 100%;
max-width: 500px;
}
.value-item {
flex: 1;
min-width: 120px;
text-align: center;
}
.value-label {
font-size: 0.8rem;
color: #3A4F66;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.value-number {
font-size: 1.8rem;
font-weight: 700;
color: #192A3D;
}
.value-unit {
font-size: 1rem;
color: #3A4F66;
}
.value-bar {
width: 100%;
height: 4px;
background: #E1E8ED;
border-radius: 2px;
margin-top: 0.75rem;
overflow: hidden;
}
.value-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.15s ease;
}
#bar-u .value-bar-fill,
#bar-u {
background: linear-gradient(90deg, #FAAC00 0%, #FFD966 100%);
}
#bar-s .value-bar-fill,
#bar-s {
background: linear-gradient(90deg, #3F40F7 0%, #1559ED 100%);
}
#bar-d .value-bar-fill,
#bar-d {
background: linear-gradient(90deg, #00A8CC 0%, #00CED1 100%);
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
width: 100%;
max-width: 600px;
}
.presets button {
border: 2px solid #E1E8ED;
background: white;
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
color: #3A4F66;
}
.presets button:hover {
border-color: #3F40F7;
background: #F8F9FF;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(63, 64, 247, 0.15);
}
.presets button.active {
background: linear-gradient(135deg, #3F40F7 0%, #1559ED 100%);
color: white;
border-color: #3F40F7;
box-shadow: 0 4px 16px rgba(63, 64, 247, 0.3);
}
.presets button:active {
transform: translateY(0);
}
.actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.actions button {
padding: 0.6rem 1.2rem;
border: 2px solid #E1E8ED;
background: white;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
color: #3A4F66;
}
.actions button:hover {
background: #F8F9FF;
border-color: #3F40F7;
}
.legend {
font-size: 0.9rem;
line-height: 1.5;
border-top: 2px solid #E1E8ED;
padding-top: 1.5rem;
max-width: 720px;
width: 100%;
color: #3A4F66;
}
.legend h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #192A3D;
font-weight: 600;
}
.legend dl {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.legend dt {
font-weight: 600;
color: #192A3D;
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend dt::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.legend dt:nth-of-type(3n+1)::before {
background: #FAAC00;
}
.legend dt:nth-of-type(3n+2)::before {
background: #3F40F7;
}
.legend dt:nth-of-type(3n)::before {
background: #00A8CC;
}
.legend dd {
margin: 0 0 0 1.5rem;
color: #3A4F66;
}
.legend dd strong {
color: #192A3D;
}
@media (min-width: 640px) {
.legend dl {
grid-template-columns: 1.1fr 2.4fr;
}
}
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: #192A3D;
color: white;
padding: 1rem 1.5rem;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(25, 42, 61, 0.3);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 100;
}
.toast.show {
opacity: 1;
pointer-events: auto;
}
</style>
</head>
<body>
<h1>Balance: Nutzerfreundlichkeit Sicherheit Datensouveränität<br>
<small>(Onboarding zu Nostr über Keycloak)</small>
</h1>
<div class="triangle-wrapper" id="triangle-wrapper" role="slider"
aria-label="Balancier-Dreieck zwischen Nutzerfreundlichkeit, Sicherheit und Datensouveränität"
aria-valuemin="0" aria-valuemax="100" tabindex="0">
<svg viewBox="0 0 300 260">
<!-- Dreiecksfläche mit Farbverlauf -->
<defs>
<linearGradient id="triGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FAAC00;stop-opacity:0.08" />
<stop offset="50%" style="stop-color:#3F40F7;stop-opacity:0.08" />
<stop offset="100%" style="stop-color:#00A8CC;stop-opacity:0.08" />
</linearGradient>
</defs>
<polygon points="150,20 20,240 280,240"
fill="url(#triGrad)"
stroke="#3F40F7"
stroke-width="2" />
<!-- Hilfslinien -->
<line x1="150" y1="20" x2="150" y2="240"
stroke="#E1E8ED" stroke-dasharray="4 4" stroke-width="1" />
<line x1="20" y1="240" x2="215" y2="130"
stroke="#E1E8ED" stroke-dasharray="4 4" stroke-width="1" />
<line x1="280" y1="240" x2="85" y2="130"
stroke="#E1E8ED" stroke-dasharray="4 4" stroke-width="1" />
<!-- Labels an den Ecken mit efabi-Farben -->
<text x="150" y="10" text-anchor="middle" font-size="12" font-weight="600" fill="#FAAC00">
Nutzerfreundlichkeit
</text>
<text x="8" y="255" text-anchor="start" font-size="12" font-weight="600" fill="#3F40F7">
Sicherheit
</text>
<text x="292" y="255" text-anchor="end" font-size="12" font-weight="600" fill="#00A8CC">
Datensouveränität
</text>
</svg>
<div class="handle" id="handle"></div>
</div>
<!-- Anzeige der aktuellen Gewichte -->
<div class="values">
<div class="value-item">
<div class="value-label">Nutzerfreundlichkeit</div>
<div class="value-number"><span id="val-u">33</span><span class="value-unit">%</span></div>
<div class="value-bar" id="bar-u">
<div class="value-bar-fill" style="width: 33%"></div>
</div>
</div>
<div class="value-item">
<div class="value-label">Sicherheit</div>
<div class="value-number"><span id="val-s">33</span><span class="value-unit">%</span></div>
<div class="value-bar" id="bar-s">
<div class="value-bar-fill" style="width: 33%"></div>
</div>
</div>
<div class="value-item">
<div class="value-label">Datensouveränität</div>
<div class="value-number"><span id="val-d">33</span><span class="value-unit">%</span></div>
<div class="value-bar" id="bar-d">
<div class="value-bar-fill" style="width: 33%"></div>
</div>
</div>
</div>
<!-- Preset-Buttons -->
<div class="presets">
<button data-preset="balanced" class="active"
title="Ausgewogener Ansatz: UX, Sicherheit und Datensouveränität im Gleichgewicht.">
⚖️ Ausgewogen
</button>
<button data-preset="ux"
title="NSEC zentral als Attribut in Keycloak, Nostr-Schlüsselmanagement weitgehend versteckt.">
⚡ UX first
</button>
<button data-preset="security"
title="Schlüssel bleibt beim Nutzenden (Signer/Client), Keycloak nur für Web-Login.">
🔒 Security first
</button>
<button data-preset="data"
title="NSEC clientseitig verschlüsselt, maximale Kontrolle beim Nutzenden.">
👤 Data first
</button>
<button data-preset="compliance"
title="Bunker unter Organisationskontrolle, starke Policies & Audits.">
✅ Compliance
</button>
</div>
<!-- Aktionen -->
<div class="actions">
<button id="btn-export" title="Aktuelle Auswahl herunterladen">📥 Exportieren</button>
<button id="btn-reset" title="Auf Standard zurücksetzen">🔄 Zurücksetzen</button>
</div>
<!-- Legende -->
<section class="legend">
<h2>📖 Was bedeutet die Position im Dreieck?</h2>
<dl>
<dt>Nutzerfreundlichkeit (oben)</dt>
<dd>
Hoher Wert: Onboarding „ein Klick", kaum Schlüsselbegriffe, wenig Reibung.
Niedriger Wert: mehr Erklärungen, Bestätigungen, zusätzliche Schritte (z.B. Seed-Backup, Passphrasen).
</dd>
<dt>Sicherheit (links unten)</dt>
<dd>
Hoher Wert: starke Trennung von Systemen, minimale Angriffsfläche, Härtung von Diensten, wenig Vollzugriffe.
Niedriger Wert: mehr Komfort, aber größere Folgen bei Kompromittierung einzelner Komponenten.
</dd>
<dt>Datensouveränität (rechts unten)</dt>
<dd>
Hoher Wert: Nutzende behalten Kontrolle über ihre Nostr-Schlüssel und Relays, Transparenz, Export/Exit möglich.
Niedriger Wert: mehr Bequemlichkeit, aber stärkere Bindung an zentrale Infrastruktur.
</dd>
</dl>
<h2>📚 Beispiele: Nostr-Onboarding über Keycloak</h2>
<dl>
<dt>UX first (nsec in Keycloak)</dt>
<dd>
<strong>Architektur:</strong> NSEC wird einmalig erzeugt und als verschlüsseltes Attribut in Keycloak gespeichert.
Web-App holt den Schlüssel via Token und signiert Events server- oder clientseitig automatisch.<br>
<strong>Effekt im Dreieck:</strong> Sehr hohe Nutzerfreundlichkeit („Einloggen & fertig"),
aber starke Abhängigkeit von Keycloak als „single point of failure" und zentrale Stelle für Schlüsselkompromittierung.
Datensouveränität ist begrenzt, Export/Relay-Wechsel eher ein Power-User-Feature.
</dd>
<dt>Security first (Key beim User)</dt>
<dd>
<strong>Architektur:</strong> NSEC liegt ausschließlich beim Nutzenden (z.B. Nostr-Signer-App, Browser-Extension).
Keycloak macht nur OIDC-Login, Nostr erfolgt über NIP-46/Delegation oder lokale Signatur.
Server sieht den NSEC nie.<br>
<strong>Effekt im Dreieck:</strong> Sehr hohe Sicherheit und hohe Datensouveränität, weil der Schlüssel
nicht in deiner Infrastruktur liegt. UX leidet: zusätzliche App/Extension, mehr Erklärungsbedarf,
Onboarding-Schritte für „Signer verbinden".
</dd>
<dt>Data first (Bunker + Schlüsselverschlüsselung)</dt>
<dd>
<strong>Architektur:</strong> NSEC wird verschlüsselt (z.B. mit Nutzer-Passphrase oder Key aus Keycloak-Token)
in einem dedizierten Bunker/Key-Service gespeichert. Signaturen laufen über den Bunker, der nur „entschlüsseln & signieren" kann,
ohne den Klartext-Schlüssel breit verfügbar zu machen.<br>
<strong>Effekt im Dreieck:</strong> Hohe Datensouveränität (klar definierter Schlüsselort, theoretisch portierbar),
hohe Sicherheit (Schlüssel nur entschlüsselt im Bunker-Kontext),
UX mittel: etwas mehr Erklärung („Was ist Bunker?"), aber ins UI gut integrierbar.
</dd>
<dt>Compliance-Modus (Org-Bunker mit Policies)</dt>
<dd>
<strong>Architektur:</strong> Bunker/Key-Service wird von der Organisation betrieben,
mit Logging, Rollen, evtl. HSM/Hardware-Backends. Keycloak-Login erteilt nur zeitlich begrenzte
Delegationen/Session-Keys für den Bunker. Richtlinien definieren, wer wann signieren darf (z.B. nur Dienstaccounts,
nur bestimmte Kinds, nur bestimmte Relays).<br>
<strong>Effekt im Dreieck:</strong> Sicherheit und Compliance sehr hoch,
Datensouveränität eher organisationsbezogen als individuell.
UX je nach UI: für Redakteur:innen kann es noch recht smooth sein, aber spontane Privatnutzung ist eingeschränkt.
</dd>
<dt>Ausgewogen (Balanced)</dt>
<dd>
<strong>Architektur:</strong> Kombination, z.B.:
Keycloak für komfortablen Login, NSEC im Bunker mit guter UI,
einfache Ex-/Import-Funktionen für Schlüssel, klare Info-Texte („Wo liegt mein Schlüssel?").<br>
<strong>Effekt im Dreieck:</strong> Kein Extrem: genug Komfort für Alltag,
genug Sicherheit/Datensouveränität für Bildungs-Infrastruktur,
ohne Nutzer:innen mit Kryptodetails zu erschlagen.
</dd>
</dl>
</section>
<div class="toast" id="toast"></div>
<script>
// ===== KONFIGURATION =====
const CONFIG = {
triangle: {
A: { x: 150, y: 20 }, // Nutzerfreundlichkeit
B: { x: 20, y: 240 }, // Sicherheit
C: { x: 280, y: 240 } // Datensouveränität
},
viewBox: { width: 300, height: 260 },
storageKey: 'triangleBalance'
};
const PRESETS = {
balanced: { alpha: 1/3, beta: 1/3, gamma: 1/3 },
ux: { alpha: 0.65, beta: 0.20, gamma: 0.15 },
security: { alpha: 0.25, beta: 0.55, gamma: 0.20 },
data: { alpha: 0.25, beta: 0.25, gamma: 0.50 },
compliance: { alpha: 0.20, beta: 0.45, gamma: 0.35 }
};
// ===== DOM ELEMENTE =====
const wrapper = document.getElementById('triangle-wrapper');
const handle = document.getElementById('handle');
const valU = document.getElementById('val-u');
const valS = document.getElementById('val-s');
const valD = document.getElementById('val-d');
const barU = document.querySelector('#bar-u .value-bar-fill');
const barS = document.querySelector('#bar-s .value-bar-fill');
const barD = document.querySelector('#bar-d .value-bar-fill');
const presetButtons = document.querySelectorAll('.presets button');
const btnExport = document.getElementById('btn-export');
const btnReset = document.getElementById('btn-reset');
const toast = document.getElementById('toast');
let dragging = false;
let currentPreset = 'balanced';
// ===== UTILITIES =====
function getPointerCoords(evt) {
if (evt.touches && evt.touches.length > 0) {
return { clientX: evt.touches[0].clientX, clientY: evt.touches[0].clientY };
}
return { clientX: evt.clientX, clientY: evt.clientY };
}
function showToast(message, duration = 2500) {
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, duration);
}
// ===== BARYZENTRISCHE KOORDINATEN =====
function cartesianToBarycentric(P) {
const { A, B, C } = CONFIG.triangle;
const denom = (B.y - C.y) * (A.x - C.x) + (C.x - B.x) * (A.y - C.y);
const alpha = ((B.y - C.y) * (P.x - C.x) + (C.x - B.x) * (P.y - C.y)) / denom;
const beta = ((C.y - A.y) * (P.x - C.x) + (A.x - C.x) * (P.y - C.y)) / denom;
const gamma = 1 - alpha - beta;
return { alpha, beta, gamma };
}
function barycentricToCartesian(b) {
const { A, B, C } = CONFIG.triangle;
return {
x: b.alpha * A.x + b.beta * B.x + b.gamma * C.x,
y: b.alpha * A.y + b.beta * B.y + b.gamma * C.y
};
}
function clampBarycentric(b) {
let { alpha, beta, gamma } = b;
alpha = Math.max(0, alpha);
beta = Math.max(0, beta);
gamma = Math.max(0, gamma);
let sum = alpha + beta + gamma;
if (sum === 0) {
alpha = beta = gamma = 1/3;
sum = 1;
}
return {
alpha: alpha / sum,
beta: beta / sum,
gamma: gamma / sum
};
}
function setHandleFromBarycentric(bary, save = true) {
const p = barycentricToCartesian(bary);
handle.style.left = p.x + 'px';
handle.style.top = p.y + 'px';
const u = Math.round(bary.alpha * 100);
const s = Math.round(bary.beta * 100);
const d = Math.round(bary.gamma * 100);
valU.textContent = u;
valS.textContent = s;
valD.textContent = d;
barU.style.width = u + '%';
barS.style.width = s + '%';
barD.style.width = d + '%';
// ARIA aktualisieren
wrapper.setAttribute('aria-valuenow', u);
wrapper.setAttribute('aria-valuetext', `Nutzerfreundlichkeit ${u}%, Sicherheit ${s}%, Datensouveränität ${d}%`);
if (save) {
saveState(bary);
currentPreset = null;
updatePresetButtons();
}
}
function updateFromPointer(evt) {
const rect = wrapper.getBoundingClientRect();
const { clientX, clientY } = getPointerCoords(evt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const scaleX = CONFIG.viewBox.width / rect.width;
const scaleY = CONFIG.viewBox.height / rect.height;
const P = { x: x * scaleX, y: y * scaleY };
let bary = cartesianToBarycentric(P);
bary = clampBarycentric(bary);
setHandleFromBarycentric(bary);
}
// ===== STORAGE =====
function saveState(bary) {
try {
localStorage.setItem(CONFIG.storageKey, JSON.stringify({
alpha: bary.alpha,
beta: bary.beta,
gamma: bary.gamma,
timestamp: Date.now()
}));
} catch (e) {
console.warn('localStorage nicht verfügbar');
}
}
function loadState() {
try {
const saved = localStorage.getItem(CONFIG.storageKey);
if (saved) {
return JSON.parse(saved);
}
} catch (e) {
console.warn('localStorage Fehler');
}
return { alpha: 1/3, beta: 1/3, gamma: 1/3 };
}
// ===== PRESETS =====
function applyPreset(name) {
const bary = PRESETS[name] || PRESETS.balanced;
currentPreset = name;
setHandleFromBarycentric(bary, true);
updatePresetButtons();
showToast(`✅ Preset „${name}" angewendet`);
}
function updatePresetButtons() {
presetButtons.forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-preset') === currentPreset);
});
}
// ===== EXPORT =====
function exportState() {
const state = {
nutzerfreundlichkeit: parseInt(valU.textContent),
sicherheit: parseInt(valS.textContent),
datensouveränität: parseInt(valD.textContent),
exportiert: new Date().toLocaleString('de-DE')
};
const dataStr = JSON.stringify(state, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `balance-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
showToast('📥 Auswahl exportiert');
}
// ===== EVENT LISTENER =====
wrapper.addEventListener('mousedown', (evt) => {
dragging = true;
updateFromPointer(evt);
});
wrapper.addEventListener('touchstart', (evt) => {
dragging = true;
updateFromPointer(evt);
}, { passive: false });
window.addEventListener('mousemove', (evt) => {
if (!dragging) return;
updateFromPointer(evt);
});
window.addEventListener('touchmove', (evt) => {
if (!dragging) return;
evt.preventDefault();
updateFromPointer(evt);
}, { passive: false });
window.addEventListener('mouseup', () => {
dragging = false;
});
window.addEventListener('touchend', () => {
dragging = false;
});
// Keyboard Navigation
wrapper.addEventListener('keydown', (evt) => {
const step = 0.05;
let bary = cartesianToBarycentric(
barycentricToCartesian({
alpha: parseFloat(valU.textContent) / 100,
beta: parseFloat(valS.textContent) / 100,
gamma: parseFloat(valD.textContent) / 100
})
);
switch(evt.key) {
case 'ArrowUp':
bary.alpha += step;
evt.preventDefault();
break;
case 'ArrowDown':
bary.alpha -= step;
evt.preventDefault();
break;
case 'ArrowLeft':
bary.beta += step;
evt.preventDefault();
break;
case 'ArrowRight':
bary.gamma += step;
evt.preventDefault();
break;
default:
return;
}
bary = clampBarycentric(bary);
setHandleFromBarycentric(bary);
});
// Preset Buttons
presetButtons.forEach(btn => {
btn.addEventListener('click', () => {
applyPreset(btn.getAttribute('data-preset'));
});
});
// Action Buttons
btnExport.addEventListener('click', exportState);
btnReset.addEventListener('click', () => {
applyPreset('balanced');
});
// ===== INITIALIZATION =====
const initialState = loadState();
setHandleFromBarycentric(initialState, false);
updatePresetButtons();
</script>
</body>
</html>