Files
KC-APP/client/web/app.js
linus 0e3b5003f6 Add code resolver, sync conflict handling, and user isolation
Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
2026-09-12 15:40:42 +02:00

1079 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// KC-App Web-Client — App-Shell im JKT-Stil, Konfi-Castle-Farben.
/// Vanilla JS, kein Build-Step; wird direkt vom NestJS-Backend ausgeliefert.
const state = {
token: null,
kcId: null,
whoami: null,
socket: null,
activeChannelId: null,
activeChannelType: null,
channels: [],
pickedUsers: new Set(),
pickedGuests: new Set(),
currentScreen: 'home',
};
/// ═══════════ Authentik OIDC (Authorization Code + PKCE) ═══════════
/// Mirrors the Flutter client's oidc.dart: public client, PKCE, no secret.
/// Defaults match the production Authentik app; override via window.__KC_OIDC__
/// (e.g. set in a small inline <script> before app.js, per environment).
const OIDC = Object.assign(
{
issuer: 'https://sso.konfi-castle.com/application/o/konfi-castle-app/',
clientId: 'K7f9mn6bP6jSjZDMYuiZCXMeVmVcqFcYNj0blJk9',
redirectUri: `${location.origin}/v1/auth/callback`,
scope: 'openid profile email groups offline_access',
},
window.__KC_OIDC__ || {},
);
function base64UrlEncode(bytes) {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function randomUrlToken(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const bytes = crypto.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (b) => chars[b % chars.length]).join('');
}
async function sha256Base64Url(text) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return base64UrlEncode(new Uint8Array(digest));
}
let oidcDiscoveryCache = null;
async function oidcDiscovery() {
if (oidcDiscoveryCache) return oidcDiscoveryCache;
const base = OIDC.issuer.endsWith('/') ? OIDC.issuer : `${OIDC.issuer}/`;
const res = await fetch(`${base}.well-known/openid-configuration`);
if (!res.ok) throw new Error(`OIDC discovery failed (${res.status})`);
oidcDiscoveryCache = await res.json();
return oidcDiscoveryCache;
}
async function beginAuthentikLogin() {
setStatus($('login-status'), 'Weiterleitung zu Authentik…');
const disc = await oidcDiscovery();
const verifier = randomUrlToken(64);
const oidcState = randomUrlToken(24);
const challenge = await sha256Base64Url(verifier);
sessionStorage.setItem('kc_oidc_verifier', verifier);
sessionStorage.setItem('kc_oidc_state', oidcState);
const url = new URL(disc.authorization_endpoint);
url.search = new URLSearchParams({
response_type: 'code',
client_id: OIDC.clientId,
redirect_uri: OIDC.redirectUri,
scope: OIDC.scope,
state: oidcState,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();
location.href = url.toString();
}
/// If the current URL is the OIDC callback (?code=…), exchanges the code
/// for tokens and logs in. Returns true if this load was a callback.
async function completeAuthentikCallbackIfPresent() {
const params = new URLSearchParams(location.search);
const code = params.get('code');
if (!code) {
if (params.get('error')) {
history.replaceState({}, '', location.pathname);
setStatus($('login-status'), `Authentik: ${params.get('error_description') || params.get('error')}`, 'error');
}
return false;
}
const expectedState = sessionStorage.getItem('kc_oidc_state');
const verifier = sessionStorage.getItem('kc_oidc_verifier');
sessionStorage.removeItem('kc_oidc_state');
sessionStorage.removeItem('kc_oidc_verifier');
history.replaceState({}, '', location.pathname);
if (!verifier || params.get('state') !== expectedState) {
setStatus($('login-status'), 'State-Mismatch — bitte erneut anmelden.', 'error');
return true;
}
setStatus($('login-status'), 'Token wird abgerufen…');
try {
const disc = await oidcDiscovery();
const res = await fetch(disc.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: OIDC.redirectUri,
client_id: OIDC.clientId,
code_verifier: verifier,
}).toString(),
});
if (!res.ok) throw new Error(`Token-Exchange fehlgeschlagen (${res.status})`);
const tokens = await res.json();
const pendingInviteToken = sessionStorage.getItem('kc_pending_verantw_invite_token');
sessionStorage.removeItem('kc_pending_verantw_invite_token');
if (pendingInviteToken) {
await redeemVerantwortlicheInvite(tokens.access_token, pendingInviteToken);
}
await onLoggedIn(tokens.access_token);
} catch (err) {
setStatus($('login-status'), err.message || 'Anmeldung fehlgeschlagen.', 'error');
}
return true;
}
const $ = (id) => document.getElementById(id);
const qs = (sel, root = document) => root.querySelector(sel);
const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
function decodeJwtPayload(token) {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
function setStatus(el, message, kind) {
if (!el) return;
el.textContent = message;
el.classList.remove('status-ok', 'status-error');
if (kind === 'ok') el.classList.add('status-ok');
if (kind === 'error') el.classList.add('status-error');
}
function showToast(message) {
const el = $('toast');
if (!el) return;
el.textContent = message;
el.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => { el.hidden = true; }, 2600);
}
function clearList(listEl, emptyMessage) {
listEl.innerHTML = '';
if (emptyMessage) {
const li = document.createElement('li');
li.className = 'list-empty';
li.textContent = emptyMessage;
listEl.appendChild(li);
}
}
function initials(firstName, lastName) {
return `${(firstName || '?')[0] || '?'}${(lastName || '')[0] || ''}`.toUpperCase();
}
async function apiFetch(path, options = {}) {
const headers = Object.assign({}, options.headers);
if (state.token) headers.Authorization = `Bearer ${state.token}`;
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
return fetch(`/api${path}`, Object.assign({}, options, { headers }));
}
/// ═══════════ Login: unified code entry ═══════════
let resolvedCode = null;
$('code-form').addEventListener('submit', async (event) => {
event.preventDefault();
const code = $('login-code').value.trim();
if (!code) return;
setStatus($('login-status'), 'Wird geprüft…');
let res;
try {
res = await apiFetch('/auth/resolve-code', { method: 'POST', body: JSON.stringify({ code }) });
} catch {
setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (!res.ok) {
setStatus($('login-status'), 'Code ungültig.', 'error');
return;
}
resolvedCode = await res.json();
resolvedCode.rawCode = code;
setStatus($('login-status'), '');
showFollowUpForm(resolvedCode);
});
function hideAllLoginForms() {
$('code-form').hidden = true;
$('guest-form').hidden = true;
$('team-form').hidden = true;
$('teamer-invite-form').hidden = true;
$('sso-panel').hidden = true;
$('verantw-invite-panel').hidden = true;
}
function showCodeForm() {
hideAllLoginForms();
$('code-form').hidden = false;
resolvedCode = null;
$('login-code').value = '';
setStatus($('login-status'), '');
$('login-code').focus();
}
qsa('.back-btn').forEach((btn) => btn.addEventListener('click', showCodeForm));
function showFollowUpForm(resolved) {
hideAllLoginForms();
switch (resolved.kind) {
case 'guest':
$('guest-form-hint').textContent = `Konfi-/Gast-Zugang für "${resolved.kcName}"`;
$('guest-form').hidden = false;
break;
case 'team_login':
$('team-form-hint').textContent = resolved.identifierType === 'email'
? 'Team-Login per E-Mail'
: `Team-Login für Gemeinde "${resolved.gemeindeName}"`;
$('team-form').hidden = false;
break;
case 'teamer_invite':
$('teamer-invite-hint').textContent = `Einladung als Teamer für "${resolved.gemeindeName}" (${resolved.kcName})`;
if (resolved.pinnedEmail) {
$('ti-email').value = resolved.pinnedEmail;
$('ti-email-label').hidden = true;
} else {
$('ti-email-label').hidden = false;
$('ti-email').value = '';
}
$('teamer-invite-form').hidden = false;
break;
case 'verantwortliche_invite':
$('verantw-invite-hint').textContent = `Einladung als Verantwortliche/r für "${resolved.gemeindeName}" (${resolved.kcName})`;
$('verantw-invite-panel').hidden = false;
break;
case 'sso':
$('sso-panel').hidden = false;
break;
default:
showCodeForm();
}
}
$('authentik-login-btn').addEventListener('click', () => {
beginAuthentikLogin().catch((err) => setStatus($('login-status'), err.message || 'Fehler.', 'error'));
});
$('verantw-invite-login-btn').addEventListener('click', () => {
sessionStorage.setItem('kc_pending_verantw_invite_token', resolvedCode?.rawCode || '');
beginAuthentikLogin().catch((err) => setStatus($('login-status'), err.message || 'Fehler.', 'error'));
});
/// ═══════════ Login: Gast/Konfi (after code resolved to "guest") ═══════════
$('guest-form').addEventListener('submit', async (event) => {
event.preventDefault();
const inviteCode = resolvedCode?.rawCode;
const firstName = $('first-name').value;
const lastName = $('last-name').value;
setStatus($('login-status'), 'Wird geprüft…');
let res;
try {
res = await apiFetch('/auth/guest', {
method: 'POST',
body: JSON.stringify({ inviteCode, firstName, lastName }),
});
} catch {
setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (!res.ok) {
setStatus($('login-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { accessToken } = await res.json();
await onLoggedIn(accessToken);
});
/// ═══════════ Login: Team (after code resolved to "team_login") ═══════════
$('team-form').addEventListener('submit', async (event) => {
event.preventDefault();
const password = $('team-password').value;
const payload = resolvedCode?.identifierType === 'email'
? { email: resolvedCode.rawCode, password }
: { gemeindeName: resolvedCode?.gemeindeName || resolvedCode?.rawCode, password };
setStatus($('login-status'), 'Wird geprüft…');
let res;
try {
res = await apiFetch('/auth/team-login', { method: 'POST', body: JSON.stringify(payload) });
} catch {
setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (!res.ok) {
setStatus($('login-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { accessToken } = await res.json();
await onLoggedIn(accessToken);
});
/// ═══════════ Login: Teamer-Invite redemption (after code resolved to "teamer_invite") ═══════════
$('teamer-invite-form').addEventListener('submit', async (event) => {
event.preventDefault();
const firstName = $('ti-first-name').value.trim();
const lastName = $('ti-last-name').value.trim();
const email = $('ti-email').value.trim();
const password = $('ti-password').value;
setStatus($('login-status'), 'Konto wird erstellt…');
let res;
try {
res = await apiFetch('/auth/teamer/register', {
method: 'POST',
body: JSON.stringify({
token: resolvedCode?.rawCode,
firstName,
lastName,
password,
email: email || undefined,
}),
});
} catch {
setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (!res.ok) {
setStatus($('login-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { accessToken } = await res.json();
await onLoggedIn(accessToken);
});
/// If an Authentik redirect was pending a Verantwortliche-invite redemption,
/// finishes that here (best-effort; login still succeeds either way).
async function redeemVerantwortlicheInvite(accessToken, inviteToken) {
try {
await fetch(`/api/onboarding/verantwortliche-invites/${encodeURIComponent(inviteToken)}/redeem`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
} catch {
// best-effort; the user is still logged in even if redemption failed
}
}
async function onLoggedIn(accessToken) {
state.token = accessToken;
const claims = decodeJwtPayload(accessToken);
state.kcId = claims?.kcId ?? null;
let me = null;
try {
const res = await apiFetch('/auth/me');
if (res.ok) me = await res.json();
} catch {
// best-effort
}
state.whoami = me;
if (!state.kcId && me?.kind === 'user' && me.memberships?.length) {
state.kcId = me.memberships.find((m) => m.kcId !== '*')?.kcId ?? null;
}
setStatus($('login-status'), '');
$('login-screen').hidden = true;
$('shell').hidden = false;
renderShellForRole();
switchScreen('home');
loadHomeDashboard();
}
$('logout-btn').addEventListener('click', () => {
if (state.socket) state.socket.close();
state.token = null;
state.kcId = null;
state.whoami = null;
state.activeChannelId = null;
$('shell').hidden = true;
$('login-screen').hidden = false;
showCodeForm();
$('team-form').reset();
$('guest-form').reset();
$('teamer-invite-form').reset();
});
/// ═══════════ Shell rendering (role-aware) ═══════════
function renderShellForRole() {
const me = state.whoami;
const badge = $('conn-badge');
badge.textContent = 'online';
badge.classList.add('online');
if (!me) return;
const isGuest = me.kind === 'guest';
const isLt = !isGuest && me.isLeitungsteam;
const isVerantwortlicher = !isGuest && (me.memberships || []).some((m) => m.role === 'GEMEINDE_VERANTWORTLICHER');
$('hdr-sub').textContent = isGuest ? 'Konfi / Gast' : (me.email || '');
$('hdr-avatar').textContent = isGuest ? 'K' : initials(me.email?.[0], '');
// Home hero
$('home-greeting').textContent = isGuest ? 'Hallo, Konfi! 👋' : `Willkommen zurück! 👋`;
$('home-role-chip').textContent = isGuest
? 'Konfi'
: (isLt ? 'Leitungsteam' : (me.memberships || []).map((m) => roleLabel(m.role)).join(', ') || 'Team');
$('home-kc-chip').textContent = state.kcId ? `KC ${state.kcId.slice(0, 8)}…` : 'kein KC';
// Profile
$('profile-av').textContent = isGuest ? 'K' : initials(me.email?.[0], '');
$('profile-name').textContent = isGuest ? 'Konfi / Gast' : (me.email || '');
$('profile-role').textContent = isGuest
? `Gast-Zugang (KC ${me.kcId || ''})`
: (isLt ? 'Leitungsteam' : (me.memberships || []).map((m) => roleLabel(m.role)).join(', '));
$('profile-kc-id').textContent = state.kcId || '';
// Wahl tab view
$('wahl-guest-view').hidden = !isGuest;
$('wahl-lt-view').hidden = isGuest ? true : !isLt;
// Chat: Gruppenchat creation only for LT / Verantwortliche
$('gruppen-card').hidden = isGuest || !(isLt || isVerantwortlicher);
}
function roleLabel(role) {
return {
LEITUNGSTEAM: 'Leitungsteam',
GEMEINDE_VERANTWORTLICHER: 'Gemeinde-Verantwortliche/r',
GEMEINDE_TEAMER: 'Gemeinde-Teamer',
}[role] || role;
}
/// ═══════════ Bottom-Nav Tab Switching ═══════════
qsa('.nb').forEach((btn) => {
btn.addEventListener('click', () => switchScreen(btn.dataset.screen));
});
function switchScreen(screenId) {
state.currentScreen = screenId;
qsa('.nb').forEach((b) => b.classList.toggle('on', b.dataset.screen === screenId));
qsa('.tab-screen').forEach((s) => (s.hidden = s.dataset.screen !== screenId));
if (screenId === 'wahl') loadWahlScreen();
if (screenId === 'chat') loadChannelsList();
if (screenId === 'files') loadFiles();
}
$('hdr-avatar').addEventListener('click', () => switchScreen('profile'));
/// ═══════════ HOME dashboard ═══════════
async function loadHomeDashboard() {
const me = state.whoami;
if (!me) return;
const isGuest = me.kind === 'guest';
// Wahl summary
try {
if (isGuest) {
const res = await apiFetch('/wahl/guest/overview');
if (res.ok) {
const { wahlen } = await res.json();
const open = wahlen?.length ?? 0;
const submitted = (wahlen || []).filter((w) => w.meinePrioritaeten).length;
$('home-wahl-summary').textContent = open
? `${open} offene Wahl(en), davon ${submitted} bereits abgegeben.`
: 'Aktuell keine offenen Wahlen.';
} else {
$('home-wahl-summary').textContent = 'Keine Daten verfügbar.';
}
} else {
$('home-wahl-summary').textContent = 'Wahl-Verwaltung im Tab "Wahl" öffnen.';
}
} catch {
$('home-wahl-summary').textContent = 'Fehler beim Laden.';
}
// Chat summary
try {
if (state.kcId) {
const res = await apiFetch(`/chat/${state.kcId}/channels`);
if (res.ok) {
const channels = await res.json();
$('home-chat-summary').textContent = channels.length
? `${channels.length} Chat(s) verfügbar.`
: 'Noch keine Chats.';
} else {
$('home-chat-summary').textContent = 'Keine Daten verfügbar.';
}
} else {
$('home-chat-summary').textContent = 'Kein KC zugeordnet.';
}
} catch {
$('home-chat-summary').textContent = 'Fehler beim Laden.';
}
// Files summary
try {
if (state.kcId) {
const res = await apiFetch(`/files/${state.kcId}`);
if (res.ok) {
const files = await res.json();
$('home-files-summary').textContent = files.length
? `${files.length} Datei(en) für dich freigegeben.`
: 'Keine Dateien verfügbar.';
} else {
$('home-files-summary').textContent = 'Keine Daten verfügbar.';
}
} else {
$('home-files-summary').textContent = 'Kein KC zugeordnet.';
}
} catch {
$('home-files-summary').textContent = 'Fehler beim Laden.';
}
}
/// ═══════════ WAHL tab ═══════════
async function loadWahlScreen() {
const me = state.whoami;
if (!me) return;
if (me.kind === 'guest') {
await loadGuestWahlOverview();
}
}
async function loadGuestWahlOverview() {
const container = $('wahl-list');
container.innerHTML = '<p class="status">Wird geladen…</p>';
let res;
try {
res = await apiFetch('/wahl/guest/overview');
} catch {
container.innerHTML = '<p class="status status-error">Netzwerkfehler.</p>';
return;
}
if (!res.ok) {
container.innerHTML = `<p class="status status-error">Fehler: ${res.status}</p>`;
return;
}
const { wahlen } = await res.json();
if (!wahlen || !wahlen.length) {
container.innerHTML = '<div class="empty"><div class="empty-icon">🗳️</div>Aktuell keine offenen Wahlen.</div>';
return;
}
container.innerHTML = '';
for (const wahl of wahlen) {
const card = document.createElement('div');
card.className = 'card card-blue';
const already = Array.isArray(wahl.meinePrioritaeten) && wahl.meinePrioritaeten.length > 0;
card.innerHTML = `
<div class="card-header">
<h2>${escapeHtml(wahl.name)} ${already ? '<span class="chip chip-green">abgegeben</span>' : ''}</h2>
<p class="card-hint">${escapeHtml(wahl.datumsSchluessel)} &middot; Teil ${escapeHtml(wahl.teil)}</p>
</div>
<div class="workshop-picker" data-wahl-id="${wahl.id}"></div>
<button class="btn btn-orange btn-full submit-wahl-btn" data-wahl-id="${wahl.id}">
${already ? 'Wünsche aktualisieren' : 'Wünsche abgeben'}
</button>
<p class="status" data-status-for="${wahl.id}"></p>
`;
container.appendChild(card);
renderWorkshopPicker(card.querySelector('.workshop-picker'), wahl.workshops, wahl.meinePrioritaeten || []);
}
qsa('.submit-wahl-btn', container).forEach((btn) => {
btn.addEventListener('click', () => submitWahlChoice(btn.dataset.wahlId));
});
}
function renderWorkshopPicker(container, workshops, initialPriorities) {
const picked = [...initialPriorities];
container.dataset.picked = JSON.stringify(picked);
function render() {
container.innerHTML = '';
for (const ws of workshops) {
const rank = picked.indexOf(ws.id);
const item = document.createElement('div');
item.className = `workshop-item${rank >= 0 ? ' picked' : ''}`;
item.innerHTML = `
<span class="workshop-rank${rank >= 0 ? '' : ' empty'}">${rank >= 0 ? rank + 1 : ''}</span>
<span class="spacer">${escapeHtml(ws.name)}</span>
<span class="chip">Plätze: ${ws.kapazitaet}</span>
`;
item.addEventListener('click', () => {
const idx = picked.indexOf(ws.id);
if (idx >= 0) picked.splice(idx, 1);
else picked.push(ws.id);
container.dataset.picked = JSON.stringify(picked);
render();
});
container.appendChild(item);
}
}
render();
}
async function submitWahlChoice(wahlId) {
const picker = qs(`.workshop-picker[data-wahl-id="${wahlId}"]`);
const statusEl = qs(`[data-status-for="${wahlId}"]`);
const prioritaeten = JSON.parse(picker?.dataset.picked || '[]');
if (!prioritaeten.length) {
setStatus(statusEl, 'Bitte mindestens einen Workshop auswählen.', 'error');
return;
}
setStatus(statusEl, 'Wird gesendet…');
let res;
try {
res = await apiFetch(`/wahl/${wahlId}/teilnehmer`, {
method: 'POST',
body: JSON.stringify({ prioritaeten }),
});
} catch {
setStatus(statusEl, 'Netzwerkfehler.', 'error');
return;
}
if (res.ok) {
setStatus(statusEl, 'Gespeichert!', 'ok');
showToast('Wünsche gespeichert');
} else {
setStatus(statusEl, `Fehler: ${res.status}`, 'error');
}
}
/// ═══════════ WAHL tab: Leitungsteam view ═══════════
$('lt-load-teilnehmer').addEventListener('click', async () => {
const wahlId = $('lt-wahl-id').value.trim();
if (!wahlId) return;
let res;
try {
res = await apiFetch(`/wahl/${wahlId}/teilnehmer`);
} catch {
setStatus($('wahl-lt-status'), 'Netzwerkfehler.', 'error');
return;
}
if (!res.ok) {
setStatus($('wahl-lt-status'), `Fehler: ${res.status}`, 'error');
return;
}
const teilnehmer = await res.json();
const list = $('lt-teilnehmer-list');
if (!teilnehmer.length) {
clearList(list, 'Keine Teilnehmer.');
return;
}
list.innerHTML = '';
for (const t of teilnehmer) {
const li = document.createElement('li');
li.textContent = `${t.name} — Wünsche: ${t.prioritaeten.length}${t.forcedWorkshopId ? ' (fest zugeteilt)' : ''}`;
list.appendChild(li);
}
setStatus($('wahl-lt-status'), `${teilnehmer.length} Teilnehmer geladen.`, 'ok');
});
$('lt-run-zuteilung').addEventListener('click', async () => {
const wahlId = $('lt-wahl-id').value.trim();
if (!wahlId) return;
setStatus($('wahl-lt-status'), 'Zuteilung läuft…');
let res;
try {
res = await apiFetch(`/wahl/${wahlId}/zuteilung/run`, { method: 'POST' });
} catch {
setStatus($('wahl-lt-status'), 'Netzwerkfehler.', 'error');
return;
}
if (res.ok) {
setStatus($('wahl-lt-status'), 'Zuteilung abgeschlossen.', 'ok');
showToast('Zuteilung durchgeführt');
} else {
setStatus($('wahl-lt-status'), `Fehler: ${res.status}`, 'error');
}
});
/// ═══════════ CHAT tab: Gruppenchat erstellen ═══════════
$('load-candidates').addEventListener('click', async () => {
if (!state.kcId) {
setStatus($('gruppe-status'), 'Kein KC zugeordnet.', 'error');
return;
}
let res;
try {
res = await apiFetch(`/chat/${state.kcId}/gruppen/participant-candidates`);
} catch {
setStatus($('gruppe-status'), 'Netzwerkfehler beim Laden.', 'error');
return;
}
if (!res.ok) {
setStatus($('gruppe-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { users, guests } = await res.json();
state.pickedUsers = new Set();
state.pickedGuests = new Set();
renderCandidateList($('candidate-users'), users, 'userId', (u) => `${u.firstName} ${u.lastName} (${roleLabel(u.role)})`, state.pickedUsers);
renderCandidateList($('candidate-guests'), guests, 'guestId', (g) => `${g.firstName} ${g.lastName}`, state.pickedGuests);
setStatus($('gruppe-status'), `${users.length} Team-Mitglieder, ${guests.length} Konfis geladen.`, 'ok');
});
function renderCandidateList(listEl, items, idKey, labelFn, pickedSet) {
clearList(listEl);
if (!items.length) {
clearList(listEl, 'Keine Kandidaten gefunden.');
return;
}
listEl.innerHTML = '';
for (const item of items) {
const id = item[idKey];
const li = document.createElement('li');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.addEventListener('change', () => {
if (checkbox.checked) pickedSet.add(id);
else pickedSet.delete(id);
li.classList.toggle('picked', checkbox.checked);
});
const label = document.createElement('span');
label.textContent = labelFn(item);
li.appendChild(checkbox);
li.appendChild(label);
li.addEventListener('click', (e) => {
if (e.target === checkbox) return;
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
});
listEl.appendChild(li);
}
}
$('create-gruppe').addEventListener('click', async () => {
if (!state.kcId) {
setStatus($('gruppe-status'), 'Kein KC zugeordnet.', 'error');
return;
}
const name = $('gruppe-name').value.trim();
setStatus($('gruppe-status'), 'Wird erstellt…');
let res;
try {
res = await apiFetch(`/chat/${state.kcId}/gruppen`, {
method: 'POST',
body: JSON.stringify({
type: 'GRUPPE',
name,
participantUserIds: [...state.pickedUsers],
participantGuestIds: [...state.pickedGuests],
}),
});
} catch {
setStatus($('gruppe-status'), 'Netzwerkfehler.', 'error');
return;
}
if (!res.ok) {
setStatus($('gruppe-status'), `Fehler: ${res.status}`, 'error');
return;
}
const channel = await res.json();
setStatus($('gruppe-status'), `Erstellt: ${channel.name || channel.id}`, 'ok');
showToast('Gruppenchat erstellt');
$('gruppe-name').value = '';
await loadChannelsList();
openChannel(channel.id, channel.type, channel.name);
});
/// ═══════════ CHAT tab: Kanalliste ═══════════
$('load-channels').addEventListener('click', loadChannelsList);
async function loadChannelsList() {
if (!state.kcId) {
clearList($('channel-list'), 'Kein KC zugeordnet.');
return;
}
let res;
try {
res = await apiFetch(`/chat/${state.kcId}/channels`);
} catch {
clearList($('channel-list'), 'Fehler beim Laden der Chats.');
return;
}
const channels = res.ok ? await res.json() : [];
state.channels = channels;
const list = $('channel-list');
if (!channels.length) {
clearList(list, 'Keine Chats verfügbar.');
return;
}
list.innerHTML = '';
for (const channel of channels) {
const li = document.createElement('li');
li.className = channel.id === state.activeChannelId ? 'active-channel' : '';
const label = document.createElement('span');
label.textContent = channel.name || channelTypeLabel(channel.type);
const chip = document.createElement('span');
chip.className = `chip ${chipClassForType(channel.type)}`;
chip.textContent = channelTypeLabel(channel.type);
li.appendChild(label);
li.appendChild(chip);
li.addEventListener('click', () => openChannel(channel.id, channel.type, channel.name));
list.appendChild(li);
}
}
function channelTypeLabel(type) {
return {
GEMEINDE_GRUPPE: 'Gemeinde',
DIREKT: 'Direkt',
LT_UEBERGREIFEND: 'Leitungsteam',
BROADCAST: 'Ankündigung',
GRUPPE: 'Gruppe',
}[type] || type;
}
function chipClassForType(type) {
return {
GEMEINDE_GRUPPE: 'chip-green',
DIREKT: 'chip-blue',
LT_UEBERGREIFEND: 'chip-orange',
BROADCAST: 'chip-orange',
GRUPPE: 'chip-blue',
}[type] || '';
}
/// ═══════════ CHAT tab: Thread öffnen + WS ═══════════
function openChannel(channelId, type, name) {
state.activeChannelId = channelId;
state.activeChannelType = type;
$('chat-thread-card').hidden = false;
$('chat-thread-title').textContent = name || channelTypeLabel(type);
$('manage-participants-btn').hidden = type !== 'GRUPPE';
$('manage-card').hidden = type !== 'GRUPPE';
qsa('#channel-list li').forEach((li) => li.classList.remove('active-channel'));
loadMessagesAndConnect(channelId);
}
async function loadMessagesAndConnect(channelId) {
const chatLog = $('chat-log');
clearList(chatLog, 'Nachrichten werden geladen…');
let res;
try {
res = await apiFetch(`/chat/channels/${channelId}/messages`);
} catch {
clearList(chatLog, 'Fehler beim Laden.');
return;
}
const messages = res.ok ? await res.json() : [];
chatLog.innerHTML = '';
if (!messages.length) {
clearList(chatLog, 'Noch keine Nachrichten.');
} else {
for (const msg of messages) appendChatMessage(msg);
chatLog.scrollTop = chatLog.scrollHeight;
}
connectWebSocket(channelId);
}
function appendChatMessage(msg) {
const chatLog = $('chat-log');
const emptyPlaceholder = chatLog.querySelector('.list-empty');
if (emptyPlaceholder) emptyPlaceholder.remove();
const isOwn = state.whoami?.kind === 'guest'
? msg.senderGuestId === state.whoami.guestId
: msg.senderUserId === state.whoami?.userId;
const li = document.createElement('li');
li.className = isOwn ? 'own' : '';
const body = document.createElement('div');
body.textContent = msg.body;
const meta = document.createElement('div');
meta.className = 'chat-meta';
const timeStr = new Date(msg.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
meta.innerHTML = isOwn ? `${timeStr} <span style="color:#53bdeb;font-weight:bold;margin-left:2px">✓✓</span>` : timeStr;
li.appendChild(body);
li.appendChild(meta);
chatLog.appendChild(li);
}
function connectWebSocket(channelId) {
if (state.socket) state.socket.close();
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`);
state.socket = socket;
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } }));
});
socket.addEventListener('message', (event) => {
const { event: name, data } = JSON.parse(event.data);
if (name === 'chat:participants-changed') {
if (data.channelId === state.activeChannelId) {
showToast('Teilnehmerliste aktualisiert');
}
return;
}
if (name !== 'chat:message') return;
if (data.channelId !== state.activeChannelId) return;
appendChatMessage(data);
$('chat-log').scrollTop = $('chat-log').scrollHeight;
});
}
$('send-message').addEventListener('click', sendChatMessage);
$('chat-message').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendChatMessage();
});
function sendChatMessage() {
const input = $('chat-message');
const body = input.value.trim();
if (!body || !state.socket || !state.activeChannelId) return;
state.socket.send(
JSON.stringify({ event: 'chat:send', data: { channelId: state.activeChannelId, body } }),
);
input.value = '';
}
/// ═══════════ CHAT tab: Teilnehmerverwaltung ═══════════
$('add-participant').addEventListener('click', () => manageParticipant('add'));
$('remove-participant').addEventListener('click', () => manageParticipant('remove'));
async function manageParticipant(action) {
const channelId = state.activeChannelId;
const userId = $('manage-user-id').value.trim();
const guestId = $('manage-guest-id').value.trim();
if (!channelId || (!userId && !guestId)) {
setStatus($('manage-status'), 'Bitte User- oder Gast-ID angeben.', 'error');
return;
}
setStatus($('manage-status'), 'Wird gesendet…');
const method = action === 'add' ? 'POST' : 'DELETE';
let res;
try {
res = await apiFetch(`/chat/gruppen/${channelId}/participants`, {
method,
body: JSON.stringify({ userId: userId || undefined, guestId: guestId || undefined }),
});
} catch {
setStatus($('manage-status'), 'Netzwerkfehler.', 'error');
return;
}
if (!res.ok) {
setStatus($('manage-status'), `Fehler: ${res.status}`, 'error');
return;
}
setStatus($('manage-status'), action === 'add' ? 'Hinzugefügt.' : 'Entfernt.', 'ok');
showToast(action === 'add' ? 'Teilnehmer hinzugefügt' : 'Teilnehmer entfernt');
}
/// ═══════════ FILES tab ═══════════
$('load-files').addEventListener('click', loadFiles);
async function loadFiles() {
if (!state.kcId) {
clearList($('file-list'), 'Kein KC zugeordnet.');
return;
}
let res;
try {
res = await apiFetch(`/files/${state.kcId}`);
} catch {
clearList($('file-list'), 'Fehler beim Laden der Dateien.');
return;
}
const files = res.ok ? await res.json() : [];
const list = $('file-list');
if (!files.length) {
clearList(list, 'Keine Dateien verfügbar.');
return;
}
list.innerHTML = '';
for (const file of files) {
const li = document.createElement('li');
li.textContent = file.filename;
li.addEventListener('click', () => downloadFile(file.id, file.filename));
list.appendChild(li);
}
}
async function downloadFile(fileId, filename) {
let res;
try {
res = await apiFetch(`/files/download/${fileId}`);
} catch {
showToast('Fehler beim Download');
return;
}
if (!res.ok) {
showToast(`Fehler: ${res.status}`);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str ?? '';
return div.innerHTML;
}
/// ═══════════ Startup: handle Authentik redirect back to us ═══════════
/// The redirect_uri is /v1/auth/callback; the SPA fallback (ServeStaticModule)
/// serves this same index.html/app.js for that path too, so we just check
/// the query string on load regardless of which path we're on.
completeAuthentikCallbackIfPresent().catch((err) => {
setStatus($('login-status'), err.message || 'Anmeldung fehlgeschlagen.', 'error');
});