import { Router } from 'express' import { config } from '../config.js' import { getCustomerByAppwriteUserId, getCustomerByEmail, getDocument, getPortalAccessByCustomerId, getUserById, listDocuments, updateDocument, Query, } from '../services/appwriteAdmin.js' import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFactor } from '../services/appwriteClient.js' import { SESSION_REVOKED_MESSAGE, clearPortalSession, 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 { startKundenansicht } from '../services/kundenansicht.js' import { logViewAction } from '../services/customerViewLog.js' import { resolveStaffAccount } from '../services/staffAccount.js' import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.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), } } /** * Backoffice-Login: Jeder Mitarbeiter aus der employees-Collection darf ins * Admin-Panel (/admin.html). Das Appwrite-Label "admin" ist ausschliesslich die * WOMS-Adminrolle (Mitarbeiterverwaltung) und KEINE Voraussetzung fuer den * Portalzugang - es wird nur als Flag in der Session mitgefuehrt. * Gibt null zurueck, wenn der Login kein Mitarbeiter ist (dann greift der * normale Kunden-Login). */ 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 } 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: Label "admin" und Passwortstand fuer die Kunden-Session (passwordVersion.js) const account = await getUserById(user.$id).catch(() => null) // Ticketsystem-Mitarbeiter (employees-Collection) bekommen die Admin-Ansicht const staff = await resolveStaffAccount(user.$id, email.trim(), account) if (staff) { // Mitarbeiter mit Appwrite-2FA: erst der Code aus der Mail macht daraus eine Portal-Sitzung // mfa-Flag ODER Appwrite verlangt einen zweiten Faktor: scheitert der Konto-Abruf, entscheidet Appwrite if (account?.mfa === true || (await sessionNeedsSecondFactor(user.sessionCookie))) { const { key, maskedEmail } = await startStaffMfa({ userId: user.$id, email: staff.email, sessionCookie: user.sessionCookie }).catch((err) => { if (err.type === 'user_email_not_verified') { err.status = 403 err.message = '2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben.' } else if (err.status === 429) { err.message = 'Zu viele Versuche. Bitte warte einige Minuten.' } throw err }) res.cookie(MFA_COOKIE, key, { ...mfaCookieOptions(), maxAge: MFA_TTL_MS }) return res.json({ mfaRequired: true, email: maskedEmail }) } await startStaffPortalSession(res, { userId: user.$id, staff }) return res.json({ success: true, role: 'admin', admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin }, }) } 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 }) }) 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] if (!project) { 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) }) // Wechsel aus dem Ticketsystem (POST /api/admin/switch-link): Einmal-Link -> Admin-Sitzung router.get('/staff-switch', async (req, res) => { const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'switch') if (!tokenData) { return res.redirect(`/login.html?error=${encodeURIComponent('Link abgelaufen, bitte anmelden')}`) } await startStaffPortalSession(res, { userId: tokenData.userId, staff: { isAdmin: Boolean(tokenData.isAdmin), name: tokenData.name, email: tokenData.email }, }) return res.redirect('/admin.html') }) // 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) // Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus if (session.role === 'admin' && session.appwriteUserId) { // Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die // konnten nur Admins bekommen, also als Admin werten. const isAdmin = session.isAdmin === undefined ? true : Boolean(session.isAdmin) let customer = null if (session.customerId) { const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null) if (doc) customer = sanitizeCustomer(doc) } await refreshPreviewCookie(req, res, { userId: session.appwriteUserId, customerId: '', email: session.adminEmail || session.email || '', name: session.adminName || session.name || 'Admin', role: 'admin', labels: isAdmin ? ['admin'] : [], authSource: 'kundenbereich-admin', }) return res.json({ authenticated: true, role: 'admin', viewAs: Boolean(session.viewAs && customer), admin: { name: session.adminName || session.name || 'Admin', email: session.adminEmail || session.email || '', isAdmin, }, customer, }) } 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' }) } }) function mfaCookieOptions() { return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', signed: true } } /** Portal-Sitzung + Preview-Cookie fuer einen Mitarbeiter (Login, Code-Bestaetigung, Wechsel aus dem Ticketsystem). */ async function startStaffPortalSession(res, { userId, staff }) { setPortalSession(res, { role: 'admin', isAdmin: staff.isAdmin, appwriteUserId: userId, name: staff.name, email: staff.email, }) const staffPreviewToken = await createPreviewSessionToken({ userId, customerId: '', email: staff.email, name: staff.name, role: 'admin', labels: staff.isAdmin ? ['admin'] : [], authSource: 'kundenbereich-admin', }) res.cookie(PREVIEW_COOKIE_NAME, staffPreviewToken, previewSessionCookieOptions()) } const MFA_GONE = 'Bitte melde dich erneut an.' router.post('/login/mfa', async (req, res) => { const key = req.signedCookies?.[MFA_COOKIE] try { const result = await verifyStaffMfa(key, req.body?.otp) if (!result.ok) { if (result.reason === 'invalid') return res.status(401).json({ error: 'Code falsch oder abgelaufen.' }) res.clearCookie(MFA_COOKIE, mfaCookieOptions()) return res.status(410).json({ error: MFA_GONE }) } res.clearCookie(MFA_COOKIE, mfaCookieOptions()) const account = await getUserById(result.userId).catch(() => null) const staff = await resolveStaffAccount(result.userId, result.email, account) if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' }) await startStaffPortalSession(res, { userId: result.userId, staff }) return res.json({ success: true, role: 'admin', admin: { name: staff.name, email: staff.email, isAdmin: staff.isAdmin }, }) } catch (err) { if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' }) return res.status(err.status || 500).json({ error: err.message || 'Anmeldung fehlgeschlagen' }) } }) router.post('/login/mfa/resend', async (req, res) => { try { if (!(await resendStaffMfa(req.signedCookies?.[MFA_COOKIE]))) { res.clearCookie(MFA_COOKIE, mfaCookieOptions()) return res.status(410).json({ error: MFA_GONE }) } return res.json({ success: true }) } catch (err) { if (err.status === 429) return res.status(429).json({ error: 'Zu viele Versuche. Bitte warte einige Minuten.' }) return res.status(err.status || 500).json({ error: err.message || 'Code konnte nicht gesendet werden' }) } }) export default router