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.
1079 lines
34 KiB
JavaScript
1079 lines
34 KiB
JavaScript
/// 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)} · 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');
|
||
});
|