- Login: Appwrite-User mit Label 'admin' (Ticketsystem-Mitarbeiter) bekommen eine Admin-Session und landen auf /admin.html - Admin-Dashboard: Statistik-Uebersicht, Projektsuche ueber alle Projekte (inkl. Preview-/Gitea-Links, Vorschau ohne Kunden-Login), Kundenliste mit Portal-Status, letztem Login und Projektanzahl - Kundennummer (code) direkt im Admin-Bereich pflegbar - 'Als Kunde ansehen': Admin sieht das Portal exakt wie der Kunde (Banner mit Rueckweg zur Admin-Uebersicht) - Kunden-Dashboard: neue Bereiche 'Meine Rechnungen' (InvoiceNinja inkl. Link ins Rechnungsportal) und 'Was wurde gemacht' (Tickets + Worksheets aus dem Ticketsystem, ohne interne Akquise-Tickets); Kundennummer im Header - Admin-API /api/portal-admin/* (Session-basiert), Kunden-API /api/invoices und /api/activity - appwriteClient: uebrig gebliebene Debug-Instrumentierung entfernt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
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
|
||
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
|
||
: '<p class="muted">Keine zusätzlichen Features freigeschaltet.</p>'
|
||
|
||
li.innerHTML = `
|
||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||
<dl>
|
||
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
|
||
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a class="btn btn-green" href="${escapeAttr(project.previewUrl)}">Projekt-Webseite öffnen</a>` : '–'}</dd>
|
||
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
|
||
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
|
||
<dt>Bereitstellung</dt><dd>${escapeHtml(project.provisioningStatus || '–')}</dd>
|
||
</dl>
|
||
${featureHtml}
|
||
`
|
||
return li
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.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 = `
|
||
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
|
||
<button type="button" class="portal-btn-outline" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
|
||
`
|
||
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 = `
|
||
<table class="simple-table">
|
||
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
${data.invoices.map((i) => `
|
||
<tr>
|
||
<td>${escapeHtml(i.number)}</td>
|
||
<td>${fmtDateShort(i.date)}</td>
|
||
<td>${fmtEuro(i.amount)}</td>
|
||
<td>${fmtEuro(i.balance)}</td>
|
||
<td>${escapeHtml(i.status)}</td>
|
||
<td>${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Rechnung ansehen</a>` : ''}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`
|
||
} 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) => `
|
||
<div class="activity-item">
|
||
<div>
|
||
<strong>${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')}</strong>
|
||
<span class="activity-status">${escapeHtml(t.status || '–')}</span>
|
||
<span class="muted"> · ${fmtDateShort(t.createdAt)}</span>
|
||
</div>
|
||
${(t.worksheets || []).map((w) => `
|
||
<div class="activity-ws">→ ${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))}` : ''}</div>
|
||
`).join('')}
|
||
</div>`).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
|