async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }) const data = await response.json().catch(() => ({})) if (!response.ok) { const fallback = response.status === 502 ? 'Server nicht erreichbar (502). Bitte kurz warten und Seite neu laden – ggf. läuft ein Update auf project.webklar.com.' : `Fehler ${response.status}` const err = new Error(data.error || fallback) err.status = response.status if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds throw err } return data } function showError(el, message) { if (!el) return el.textContent = message el.classList.remove('hidden') } function hideError(el) { if (el) el.classList.add('hidden') } function featuresForProject(features, projectId) { return features.filter((f) => !f.projectId || f.projectId === projectId) } function renderProjectCard(project, features) { const li = document.createElement('li') li.className = 'project-card' const projectFeatures = featuresForProject(features, project.id) const featureHtml = projectFeatures.length ? `
${projectFeatures.map((f) => `${escapeHtml(f.featureKey)}`).join('')}
` : '

Keine zusätzlichen Features freigeschaltet.

' li.innerHTML = `

${escapeHtml(project.projectName || project.subdomain || 'Projekt')}

Subdomain
${escapeHtml(project.subdomain || '–')}
Vorschau
${project.previewUrl ? `Projekt-Webseite öffnen` : '–'}
Live-Domain
${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}
Status
${escapeHtml(project.status || '–')}
Bereitstellung
${escapeHtml(project.provisioningStatus || '–')}
${featureHtml} ` return li } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapeAttr(str) { return escapeHtml(str).replace(/'/g, ''') } function getQueryParam(name) { return new URLSearchParams(window.location.search).get(name) || '' } function safeRedirectUrl(url) { try { const u = new URL(url) if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') { return u.toString() } } catch { /* ignore invalid URL */ } return null } function isProjectPreviewReady(project) { const status = (project?.provisioningStatus || project?.status || '').toLowerCase() return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl) } function previewSubdomainFromUrl(url) { try { const host = new URL(url).hostname.toLowerCase() const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/) return match ? match[1] : '' } catch { return '' } } async function canAccessPreviewUrl(url) { const subdomain = previewSubdomainFromUrl(url) if (!subdomain) return false try { const access = await fetch( `/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`, { credentials: 'same-origin' } ).then((r) => r.json()) return Boolean(access.allowed) } catch { return false } } async function resolvePostLoginTarget() { const next = safeRedirectUrl(getQueryParam('next')) if (next && (await canAccessPreviewUrl(next))) { return next } try { const { projects } = await api('/api/projects') const readyProjects = (projects || []).filter(isProjectPreviewReady) if (readyProjects.length === 1) { return readyProjects[0].previewUrl } } catch { /* dashboard fallback */ } return '/dashboard.html' } async function postLoginRedirect() { window.location.href = await resolvePostLoginTarget() } async function initLoginPage() { const form = document.getElementById('login-form') const errorEl = document.getElementById('login-error') const btn = document.getElementById('login-btn') const errorFromQuery = getQueryParam('error') if (errorFromQuery) { showError(errorEl, errorFromQuery) } async function applyCooldown(seconds) { if (seconds <= 0) return showError( errorEl, `Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).` ) btn.disabled = true const tick = setInterval(async () => { const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({})) const left = status.retryAfterSeconds || 0 if (left <= 0) { clearInterval(tick) hideError(errorEl) btn.disabled = false return } showError( errorEl, `Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).` ) }, 1000) } try { const status = await fetch('/api/auth/login-status').then((r) => r.json()) if (status.blocked) { await applyCooldown(status.retryAfterSeconds) } } catch { /* ignore */ } try { const me = await api('/api/auth/me') if (me.authenticated && me.role === 'admin' && !me.viewAs) { window.location.href = '/admin.html' return } if (me.authenticated && me.customer) { await postLoginRedirect() return } } catch { /* not logged in */ } form?.addEventListener('submit', async (e) => { e.preventDefault() if (btn.disabled) return errorEl.classList.add('hidden') btn.disabled = true btn.classList.add('is-loading') btn.setAttribute('aria-busy', 'true') let cooldownActive = false try { const result = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: document.getElementById('email').value, password: document.getElementById('password').value, }), }) if (result.role === 'admin') { window.location.href = '/admin.html' return } await postLoginRedirect() return } catch (err) { showError(errorEl, err.message) if (err.status === 429) { cooldownActive = true await applyCooldown(err.retryAfterSeconds || 900) } } finally { btn.classList.remove('is-loading') btn.removeAttribute('aria-busy') if (!cooldownActive) btn.disabled = false } }) } function fmtDateShort(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } function fmtEuro(value) { return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) } function renderViewAsBanner(me) { const banner = document.getElementById('viewas-banner') if (!banner || !me.viewAs) return banner.classList.remove('hidden') banner.innerHTML = ` Admin-Ansicht: Du siehst das Portal als ${escapeHtml(me.customer?.name || 'Kunde')}. ` document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => { await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {}) window.location.href = '/admin.html' }) } async function loadCustomerInvoices() { const section = document.getElementById('invoices-section') const body = document.getElementById('invoices-body') if (!section || !body) return try { const data = await api('/api/invoices') if (!data.linked || !data.invoices.length) return section.classList.remove('hidden') body.innerHTML = ` ${data.invoices.map((i) => ` `).join('')}
Nr.DatumBetragOffenStatus
${escapeHtml(i.number)} ${fmtDateShort(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)} ${escapeHtml(i.status)} ${i.portalUrl ? `Rechnung ansehen` : ''}
` } catch { /* Rechnungen sind optional */ } } async function loadCustomerActivity() { const section = document.getElementById('activity-section') const body = document.getElementById('activity-body') if (!section || !body) return try { const { tickets } = await api('/api/activity') if (!tickets || !tickets.length) return section.classList.remove('hidden') body.innerHTML = tickets.map((t) => `
${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')} ${escapeHtml(t.status || '–')} · ${fmtDateShort(t.createdAt)}
${(t.worksheets || []).map((w) => `
→ ${fmtDateShort(w.date)}${w.employeeName ? ` ${escapeHtml(w.employeeName)}` : ''}${w.serviceType ? ` (${escapeHtml(w.serviceType)}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''})` : ''}${w.details ? `: ${escapeHtml(w.details.slice(0, 200))}` : ''}
`).join('')}
`).join('') } catch { /* Aktivität ist optional */ } } async function initDashboardPage() { const meta = document.getElementById('customer-meta') const list = document.getElementById('projects') const loading = document.getElementById('loading') const empty = document.getElementById('empty') const loadError = document.getElementById('load-error') const logoutBtn = document.getElementById('logout-btn') logoutBtn?.addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST' }) window.location.href = '/login.html' }) try { const me = await api('/api/auth/me') if (!me.authenticated) { window.location.href = '/login.html' return } if (me.role === 'admin' && !me.customer) { window.location.href = '/admin.html' return } if (!me.customer) { window.location.href = '/login.html' return } const customer = me.customer renderViewAsBanner(me) const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : '' meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix const [{ projects }, { features }] = await Promise.all([ api('/api/projects'), api('/api/features'), ]) loading.classList.add('hidden') if (projects.length) { list.classList.remove('hidden') list.innerHTML = '' for (const project of projects) { list.appendChild(renderProjectCard(project, features)) } } else { empty.classList.remove('hidden') } loadCustomerInvoices() loadCustomerActivity() } catch (err) { loading.classList.add('hidden') if (err.status === 401 || err.message.includes('Nicht angemeldet')) { window.location.href = '/login.html' return } showError(loadError, err.message) } } window.initLoginPage = initLoginPage window.initDashboardPage = initDashboardPage