import { Router } from 'express' import { config } from '../config.js' import { getCustomerByAppwriteUserId, getCustomerByEmail, getDocument, getPortalAccessByCustomerId, getUserById, listDocuments, updateDocument, Query, } from '../services/appwriteAdmin.js' import { appwriteSessionFetch, loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js' import { SESSION_REVOKED_MESSAGE, clearPortalSession, parsePortalSession, requireSession, setPortalSession, } from '../middleware/session.js' import { changeCustomerPassword } from '../services/customerPassword.js' import { isSessionPasswordCurrent, rememberPasswordVersion } from '../services/passwordVersion.js' import { PREVIEW_COOKIE_NAME, clearPreviewSession, createPreviewSessionToken, previewSessionCookieOptions, verifyPreviewSessionToken, } from '../services/previewSession.js' import { isPreviewProjectReady } from '../services/previewReadiness.js' import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js' import { getPortalAccessStatus } from '../services/portalAccessStatus.js' import { VIEW_EXPIRED_MESSAGE, endKundenansicht, isExpired, isKundenansicht, logOnce, setKundenansichtPreview, startKundenansicht, } from '../services/kundenansicht.js' import { logViewAction } from '../services/customerViewLog.js' import { isStaffAccount } from '../services/staffLabels.js' const router = Router() function sanitizeCustomer(customer) { return { id: customer.$id, code: customer.code || '', name: customer.name || '', companyName: customer.companyName || '', email: customer.email || '', phone: customer.phone || '', location: customer.location || '', customerStatus: customer.customerStatus || '', portalAccessEnabled: Boolean(customer.portalAccessEnabled), } } /** Kunden-Login: Kundenkonto mit freigeschaltetem, aktivem Portalzugang, sonst Fehler mit status 403. */ async function validatePortalAccess(appwriteUserId, email) { let customer = await getCustomerByAppwriteUserId(appwriteUserId) if (!customer && email) { customer = await getCustomerByEmail(email) } if (!customer) { const error = new Error( `Kein Kundenkonto für diesen Login gefunden. Im Ticketsystem customers.appwriteUserId auf "${appwriteUserId}" setzen (E-Mail: ${email}).` ) error.status = 403 throw error } if (!customer.portalAccessEnabled) { const error = new Error('Portalzugang ist nicht freigeschaltet.') error.status = 403 throw error } const portalAccess = await getPortalAccessByCustomerId(customer.$id) if (!portalAccess || !portalAccess.enabled) { const error = new Error('Portalzugang ist deaktiviert.') error.status = 403 throw error } const status = (customer.customerStatus || '').toLowerCase() if (!config.allowedCustomerStatuses.includes(status)) { const error = new Error('Kundenkonto ist nicht aktiv.') error.status = 403 throw error } return { customer, portalAccess } } async function syncCustomerAppwriteUserId(customer, appwriteUserId) { if (!customer?.$id || !appwriteUserId) return customer if (customer.appwriteUserId === appwriteUserId) return customer const now = new Date().toISOString() return updateDocument(config.collections.customers, customer.$id, { appwriteUserId, portalAccessEnabled: true, updatedAt: now, }) } async function resolveSessionCustomer(session) { if (!session?.customerId) return null let customer = await getDocument(config.collections.customers, session.customerId).catch(() => null) if (customer) return customer if (session.appwriteUserId) { customer = await getCustomerByAppwriteUserId(session.appwriteUserId) if (customer) return customer } if (session.email) { customer = await getCustomerByEmail(session.email) } return customer } function isAdminSession(session) { return session?.role === 'admin' || (session?.labels || []).includes('admin') } function safePreviewRedirectUrl(url) { try { const parsed = new URL(String(url || '')) if ( parsed.hostname.endsWith('.project.webklar.com') || parsed.hostname === 'project.webklar.com' ) { return parsed.toString() } } catch { /* ignore */ } return null } const STAFF_LOGIN_BLOCKED = 'Mitarbeiter arbeiten im Ticketsystem.' router.get('/login-status', (_req, res) => { const retryAfterSeconds = getLoginCooldownRemainingSec() res.json({ blocked: retryAfterSeconds > 0, retryAfterSeconds, }) }) router.post('/login', async (req, res) => { const { email, password } = req.body || {} if (!email || !password) { return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' }) } try { const user = await loginWithAppwrite(email.trim(), password) // Appwrite-Konto: Labels und Passwortstand fuer die Kunden-Session (passwordVersion.js). Ohne Konto keine // Entscheidung, ob Mitarbeiter -> Anmeldung ablehnen statt raten (fail closed) const account = await getUserById(user.$id).catch(() => null) if (!account) { await appwriteSessionFetch('/account/sessions/current', { method: 'DELETE', sessionCookie: user.sessionCookie }).catch(() => {}) return res.status(503).json({ error: 'Anmeldung gerade nicht möglich. Bitte in einer Minute erneut versuchen.' }) } // Mitarbeiter (Label mitarbeiter oder admin, mit oder ohne 2FA) arbeiten im Ticketsystem: // keine Portal-Sitzung, die eben erzeugte Appwrite-Sitzung wieder beenden (best effort) if (isStaffAccount(account)) { await appwriteSessionFetch('/account/sessions/current', { method: 'DELETE', sessionCookie: user.sessionCookie }).catch((err) => console.warn('[auth] Appwrite-Sitzung eines Mitarbeiters nicht beendet:', err.message) ) return res.status(403).json({ error: STAFF_LOGIN_BLOCKED, ticketUrl: config.womsBaseUrl }) } let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim()) customer = await syncCustomerAppwriteUserId(customer, user.$id) setPortalSession(res, { customerId: customer.$id, appwriteUserId: user.$id, name: customer.name || user.name || '', email: customer.email || user.email || email, pwv: account?.passwordUpdate || undefined, }) if (account) rememberPasswordVersion(user.$id, account.passwordUpdate) const previewToken = await createPreviewSessionToken({ userId: user.$id, customerId: customer.$id, email: customer.email || user.email || email, name: customer.name || user.name || '', role: 'customer', labels: [], authSource: 'kundenbereich', }) res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions()) try { await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, { lastLoginAt: new Date().toISOString(), }) } catch (err) { console.warn('[auth] lastLoginAt update failed:', err.message) } return res.json({ success: true, role: 'customer', customer: sanitizeCustomer(customer) }) } catch (err) { const status = err.status || 500 if (status === 429) { return res.status(429).json({ error: err.message || 'Zu viele Anmeldeversuche. Bitte warte einige Minuten, bevor du es erneut versuchst.', retryAfterSeconds: getLoginCooldownRemainingSec(), }) } if (err?.message?.includes('not authorized')) { return res.status(500).json({ error: 'Server-Konfiguration: APPWRITE_API_KEY benötigt databases.read für woms-database (customers, customerPortalAccess).', }) } return res.status(status).json({ error: err.message || 'Anmeldung fehlgeschlagen' }) } }) // Passwort aendern (Konto-Menue "Passwort ändern"). Die eigene Session bekommt den neuen // Passwortstand; alle anderen Sessions des Kunden gelten danach nicht mehr. router.post('/password', requireSession, async (req, res) => { if (req.session.role === 'admin') { return res.status(403).json({ error: 'Im Ansichtsmodus kann das Passwort nicht geändert werden.' }) } try { const { passwordVersion } = await changeCustomerPassword(req.session, req.body) setPortalSession(res, { ...req.session, pwv: passwordVersion || undefined }) return res.json({ success: true }) } catch (err) { return res.status(err.status || 500).json({ error: err.message || 'Das Passwort konnte nicht geändert werden.', ...(err.code ? { code: err.code } : {}), ...(err.retryAfterSeconds ? { retryAfterSeconds: err.retryAfterSeconds } : {}), }) } }) router.post('/logout', (_req, res) => { clearPortalSession(res) clearPreviewSession(res) res.json({ success: true }) }) // Kundenansicht beenden ("Beenden" im Banner): Protokoll, Cookies weg, zurueck auf die Kundenseite im Ticketsystem router.post('/kundenansicht/ende', async (req, res) => { const session = parsePortalSession(req) if (!isKundenansicht(session)) return res.json({ redirect: `${config.womsBaseUrl}/customers` }) await logOnce(session, 'beendet') endKundenansicht(res) return res.json({ redirect: `${config.womsBaseUrl}/customers/${encodeURIComponent(session.customerId)}` }) }) router.get('/preview-access', async (req, res) => { const token = req.cookies?.[PREVIEW_COOKIE_NAME] if (!token) { return res.status(401).json({ allowed: false }) } const session = await verifyPreviewSessionToken(token) const admin = isAdminSession(session) if (!session || (!session.customerId && !admin)) { return res.status(401).json({ allowed: false }) } const subdomain = String(req.query.subdomain || '') .trim() .toLowerCase() if (!subdomain) { return res.status(400).json({ allowed: false, error: 'subdomain required' }) } try { const queries = [Query.equal('subdomain', subdomain), Query.limit(1)] if (!admin) { queries.splice(1, 0, Query.equal('customerId', session.customerId)) } const projects = await listDocuments(config.collections.websiteProjects, queries) const project = projects[0] // Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus) if (!project || (project.archived && !admin)) { return res.json({ allowed: false, reason: 'no_project' }) } if (!isPreviewProjectReady(project)) { return res.json({ allowed: false, reason: 'not_ready', projectId: project.$id }) } return res.json({ allowed: true, projectId: project.$id }) } catch (err) { return res.status(500).json({ allowed: false, error: err.message || 'Fehler' }) } }) router.get('/staff-preview', async (req, res) => { const tokenValue = String(req.query.token || '').trim() if (!tokenValue) { return res.status(400).send('Token fehlt') } const tokenData = consumeStaffPreviewToken(tokenValue) if (!tokenData) { return res.status(410).send('Der Preview-Link ist abgelaufen. Bitte erneut aus dem Ticketsystem oeffnen.') } const previewUrl = safePreviewRedirectUrl(tokenData.previewUrl) if (!previewUrl) { return res.status(400).send('Ungueltige Preview-URL') } const previewToken = await createPreviewSessionToken({ userId: tokenData.userId, customerId: tokenData.customerId || '', email: tokenData.email || '', name: tokenData.name || 'Admin', role: 'admin', labels: ['admin'], authSource: 'ticket-admin', }) res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions()) return res.redirect(previewUrl) }) // Kundenansicht aus dem Ticketsystem (POST /api/team/customers/:id/kundenansicht): Einmal-Link -> Portal des Kunden router.get('/kundenansicht', async (req, res) => { const gone = (text) => res.status(410).type('text/plain; charset=utf-8').send(text) const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'kundenansicht') if (!tokenData) return gone('Der Link ist abgelaufen. Bitte die Kundenansicht im Ticketsystem erneut öffnen.') try { const { customer, hasAccess } = await getPortalAccessStatus(tokenData.customerId) if (!customer || !hasAccess) return gone('Kein Portal-Zugang. Bitte die Kundenansicht im Ticketsystem erneut öffnen.') const session = await startKundenansicht(res, { staff: tokenData.staff, customer }) await logViewAction({ customerId: customer.$id, staffUserId: session.appwriteUserId, staffName: session.adminName, action: 'geoeffnet', }) return res.redirect('/dashboard.html') } catch (err) { console.error('[kundenansicht] Öffnen fehlgeschlagen:', err.message) return res.status(500).type('text/plain; charset=utf-8').send('Die Kundenansicht konnte nicht geöffnet werden.') } }) // Selbstheilung: Portal-Session lebt oft laenger als das preview_session-Cookie // (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges // Preview-Cookie laufen die *.project.webklar.com-Hosts in eine 302-Schleife. // Deshalb stellt /me das Preview-Cookie automatisch neu aus, solange die // Portal-Session gueltig ist. async function refreshPreviewCookie(req, res, payload) { try { const existing = req.cookies?.[PREVIEW_COOKIE_NAME] if (existing && (await verifyPreviewSessionToken(existing))) return const token = await createPreviewSessionToken(payload) res.cookie(PREVIEW_COOKIE_NAME, token, previewSessionCookieOptions()) } catch (err) { console.warn('[auth] preview-cookie refresh failed:', err.message) } } router.get('/me', async (req, res) => { const raw = req.signedCookies?.[config.cookieName] if (!raw) { return res.json({ authenticated: false }) } try { const session = JSON.parse(raw) // Kundenansicht aus dem Ticketsystem: endet nach 2 Stunden, Preview-Zugang nur fuer diesen Kunden if (isKundenansicht(session)) { if (isExpired(session)) { endKundenansicht(res) await logOnce(session, 'abgelaufen') return res.json({ authenticated: false, reason: 'view_expired', message: VIEW_EXPIRED_MESSAGE }) } const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null) if (!doc) { endKundenansicht(res) return res.json({ authenticated: false }) } const existing = req.cookies?.[PREVIEW_COOKIE_NAME] const preview = existing ? await verifyPreviewSessionToken(existing) : null if (preview?.role !== 'customer' || preview.customerId !== session.customerId) { const remainingSec = Math.max(1, Math.ceil((session.viewExpiresAt - Date.now()) / 1000)) await setKundenansichtPreview(res, session, remainingSec).catch((err) => console.warn('[kundenansicht] Preview-Cookie nicht gesetzt:', err.message) ) } return res.json({ authenticated: true, role: 'admin', viewAs: true, viewId: session.viewId, viewExpiresAt: session.viewExpiresAt, admin: { name: session.adminName || 'Mitarbeiter', email: session.adminEmail || '', isAdmin: Boolean(session.isAdmin) }, customer: sanitizeCustomer(doc), }) } // Mitarbeiter-Sitzung von frueher (Login im Portal oder "Als Kunde ansehen" im frueheren Admin-Panel): // beenden, Mitarbeiter arbeiten im Ticketsystem if (session.role === 'admin') { clearPortalSession(res) clearPreviewSession(res) return res.json({ authenticated: false, reason: 'staff', redirect: config.womsBaseUrl }) } if (!session.customerId || !session.appwriteUserId) { return res.json({ authenticated: false }) } // Passwort inzwischen geaendert (anderes Geraet oder durch uns): neu anmelden if (!(await isSessionPasswordCurrent(session))) { clearPortalSession(res) clearPreviewSession(res) return res.json({ authenticated: false, reason: 'password_changed', message: SESSION_REVOKED_MESSAGE }) } let customer = await resolveSessionCustomer(session) if (!customer) { clearPortalSession(res) clearPreviewSession(res) return res.json({ authenticated: false }) } try { await validatePortalAccess(session.appwriteUserId, session.email || customer.email) } catch { clearPortalSession(res) clearPreviewSession(res) return res.json({ authenticated: false }) } if (customer.appwriteUserId !== session.appwriteUserId) { customer = await syncCustomerAppwriteUserId(customer, session.appwriteUserId) } await refreshPreviewCookie(req, res, { userId: session.appwriteUserId, customerId: customer.$id, email: customer.email || session.email || '', name: customer.name || session.name || '', role: 'customer', labels: [], authSource: 'kundenbereich', }) return res.json({ authenticated: true, role: 'customer', customer: sanitizeCustomer(customer), }) } catch (err) { return res.status(500).json({ error: err.message || 'Fehler beim Laden' }) } }) export default router