Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:27:21 +00:00
1 parent fdf594706d
commit 1cd776a2ee
10 files changed
+277 -7

No files matched your search

+2 -2
View File
@@ -11,12 +11,12 @@ export function sessionMiddleware() {
return cookieParser(config.sessionSecret)
}
export function setPortalSession(res, data) {
export function setPortalSession(res, data, { maxAgeMs = SESSION_MAX_AGE_MS } = {}) {
res.cookie(config.cookieName, JSON.stringify(data), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_MS,
maxAge: maxAgeMs,
signed: true,
})
}
+25
View File
@@ -28,6 +28,9 @@ import {
} 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'
@@ -343,6 +346,28 @@ router.get('/staff-switch', async (req, res) => {
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.
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { getPortalAccessStatus } from '../../services/portalAccessStatus.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
// Kunden im Team-Eingang (/api/team/customers), requireStaffJwt sitzt in routes/team/index.js
const router = Router()
// Kundenansicht oeffnen: Einmal-Link (60 s), den das Ticketsystem in einem neuen Tab aufruft
router.post('/:id/kundenansicht', async (req, res) => {
try {
const { customer, hasAccess } = await getPortalAccessStatus(req.params.id)
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' })
if (!hasAccess) return res.status(409).json({ code: 'no_portal_access', error: 'Kein Portal-Zugang' })
const token = createStaffPreviewToken({ purpose: 'kundenansicht', customerId: customer.$id, staff: req.staff })
return res.json({ openUrl: `${config.publicBaseUrl}/api/auth/kundenansicht?token=${encodeURIComponent(token)}` })
} catch (err) {
return res.status(500).json({ error: err.message || 'Kundenansicht konnte nicht geöffnet werden' })
}
})
export default router
+2
View File
@@ -1,8 +1,10 @@
import { Router } from 'express'
import { requireStaffJwt } from '../../middleware/staffJwt.js'
import customersRoutes from './customers.js'
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
const router = Router()
router.use(requireStaffJwt)
router.use('/customers', customersRoutes)
export default router
+80
View File
@@ -0,0 +1,80 @@
import { randomBytes } from 'node:crypto'
import { clearPortalSession, setPortalSession } from '../middleware/session.js'
import { logViewAction } from './customerViewLog.js'
import {
PREVIEW_COOKIE_NAME,
clearPreviewSession,
createPreviewSessionToken,
previewSessionCookieOptions,
} from './previewSession.js'
/**
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
*/
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
export const isKundenansicht = (session) => Boolean(session?.viewAs && session.viewId)
export const isExpired = (session, now = Date.now()) => now >= Number(session?.viewExpiresAt || 0)
/** Portal-Sitzung und Preview-Zugang fuer genau diesen Kunden setzen; liefert die Sitzung. */
export async function startKundenansicht(res, { staff, customer, now = Date.now() }) {
const session = {
role: 'admin',
viewAs: true,
viewId: randomBytes(16).toString('hex'),
viewExpiresAt: now + KUNDENANSICHT_TTL_MS,
isAdmin: Boolean(staff.isAdmin),
appwriteUserId: staff.appwriteUserId,
adminName: staff.name || 'Mitarbeiter',
adminEmail: staff.email || '',
customerId: customer.$id,
name: customer.name || '',
email: customer.email || '',
}
setPortalSession(res, session, { maxAgeMs: KUNDENANSICHT_TTL_MS })
await setKundenansichtPreview(res, session, KUNDENANSICHT_TTL_MS / 1000)
return session
}
/** Preview-Cookie wie beim Kunden selbst (nur seine Projekte), hoechstens bis zum Ende der Kundenansicht. */
export async function setKundenansichtPreview(res, session, maxAgeSec) {
const token = await createPreviewSessionToken(
{
userId: session.appwriteUserId,
customerId: session.customerId,
email: session.email || '',
name: session.name || '',
role: 'customer',
labels: [],
authSource: 'kundenansicht',
},
{ maxAgeSec }
)
return res.cookie(PREVIEW_COOKIE_NAME, token, previewSessionCookieOptions({ maxAgeSec }))
}
export function endKundenansicht(res) {
clearPortalSession(res)
clearPreviewSession(res)
}
// "beendet"/"abgelaufen" je Ansicht nur einmal protokollieren (parallele Anfragen eines Tabs)
const LOGGED_TTL_MS = 3 * 60 * 60 * 1000
const logged = new Map()
export async function logOnce(session, action) {
const now = Date.now()
for (const [key, at] of logged) if (now - at > LOGGED_TTL_MS) logged.delete(key)
const key = `${session.viewId}:${action}`
if (logged.has(key)) return
logged.set(key, now)
await logViewAction({
customerId: session.customerId,
staffUserId: session.appwriteUserId,
staffName: session.adminName,
action,
})
}
+20
View File
@@ -0,0 +1,20 @@
import { config } from '../config.js'
import { getDocument, getPortalAccessByCustomerId } from './appwriteAdmin.js'
/**
* Portal-Zugang eines Kunden fuer das Ticketsystem: hasAccess wie beim Login (customers.portalAccessEnabled und ein
* freigeschaltetes customerPortalAccess-Dokument), dazu der letzte Login. customer = null bei unbekanntem Kunden.
*/
export async function getPortalAccessStatus(customerId) {
const customer = await getDocument(config.collections.customers, customerId).catch((err) => {
if (err.status === 404) return null
throw err
})
if (!customer) return { customer: null, hasAccess: false, lastLoginAt: null }
const access = await getPortalAccessByCustomerId(customer.$id)
return {
customer,
hasAccess: Boolean(customer.portalAccessEnabled && access?.enabled),
lastLoginAt: access?.lastLoginAt || null,
}
}
+5 -4
View File
@@ -5,11 +5,12 @@ export const PREVIEW_COOKIE_NAME = 'preview_session'
const secret = new TextEncoder().encode(config.preview.sessionSecret)
export async function createPreviewSessionToken(payload) {
/** maxAgeSec kuerzer als die Standard-Laufzeit z. B. fuer die Kundenansicht (2 Stunden). */
export async function createPreviewSessionToken(payload, { maxAgeSec = config.preview.sessionMaxAge } = {}) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${config.preview.sessionMaxAge}s`)
.setExpirationTime(`${maxAgeSec}s`)
.sign(secret)
}
@@ -22,14 +23,14 @@ export async function verifyPreviewSessionToken(token) {
}
}
export function previewSessionCookieOptions() {
export function previewSessionCookieOptions({ maxAgeSec = config.preview.sessionMaxAge } = {}) {
return {
domain: config.preview.cookieDomain,
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: config.preview.sessionMaxAge * 1000,
maxAge: maxAgeSec * 1000,
}
}
+2 -1
View File
@@ -22,7 +22,8 @@ export function createStaffPreviewToken(payload) {
/**
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
* ('switch'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
* Zweck ist trotzdem verbraucht.
*/
export function consumeStaffPreviewToken(token, purpose = 'preview') {
purgeExpired()