Kundenansicht: Beenden mit Rueckweg ins Ticketsystem, /me mit Ansichts-Kennung, Team-Routen Portal-Status, Protokoll, Preview-Link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:32:59 +00:00
1 parent 7d8ba56088
commit c5afd42a5f
9 files changed
+284 -23

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
import { parsePortalSession } from './session.js'
import { endKundenansicht, isExpired, isKundenansicht, logOnce } from '../services/kundenansicht.js'
import { VIEW_EXPIRED_MESSAGE, endKundenansicht, isExpired, isKundenansicht, logOnce } from '../services/kundenansicht.js'
/**
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
@@ -43,7 +43,7 @@ export async function kundenansichtGuard(req, res, next) {
if (current && isExpired(session)) {
endKundenansicht(res)
await logOnce(session, 'abgelaufen')
return res.status(401).json({ code: 'view_expired', error: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' })
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
}
const header = req.get('X-Kundenansicht')
if (current && header && header !== session.viewId) return replaced(res, session)
+32 -20
View File
@@ -35,21 +35,18 @@ function previewUrlForSubdomain(subdomain) {
return `https://${safe}.${config.preview.baseHost}`
}
router.use(requireAppwriteStaff)
router.post('/', async (req, res) => {
const user = req.appwriteUser
if (!isStaffAdmin(user)) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
}
const subdomain = String(req.body?.subdomain || '')
/**
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview). Genutzt von dieser
* Admin-Route und vom Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
*/
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
const subdomain = String(rawSubdomain || '')
.trim()
.toLowerCase()
const previewUrl = safePreviewUrl(req.body?.previewUrl) || previewUrlForSubdomain(subdomain)
const previewUrl = safePreviewUrl(rawPreviewUrl) || previewUrlForSubdomain(subdomain)
if (!subdomain || !previewUrl) {
return res.status(400).json({ error: 'subdomain oder gueltige previewUrl erforderlich' })
return { status: 400, body: { error: 'subdomain oder gueltige previewUrl erforderlich' } }
}
try {
@@ -59,27 +56,42 @@ router.post('/', async (req, res) => {
])
const project = projects[0]
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden' })
return { status: 404, body: { error: 'Projekt nicht gefunden' } }
}
if (!isPreviewProjectReady(project)) {
return res.status(409).json({ error: 'Projekt-Vorschau ist noch nicht bereit' })
return { status: 409, body: { error: 'Projekt-Vorschau ist noch nicht bereit' } }
}
const token = createStaffPreviewToken({
userId: user.$id,
email: user.email || '',
name: user.name || user.email || 'Admin',
userId,
email: email || '',
name: name || email || 'Admin',
previewUrl,
customerId: project.customerId || '',
})
const portalBase = `https://${config.preview.baseHost}`
return res.json({
openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}`,
})
return { status: 200, body: { openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}` } }
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Preview-Login fehlgeschlagen' })
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
}
}
router.use(requireAppwriteStaff)
router.post('/', async (req, res) => {
const user = req.appwriteUser
if (!isStaffAdmin(user)) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
}
const { status, body } = await buildStaffPreviewLink({
userId: user.$id,
email: user.email,
name: user.name,
subdomain: req.body?.subdomain,
previewUrl: req.body?.previewUrl,
})
return res.status(status).json(body)
})
export default router
+50 -1
View File
@@ -14,6 +14,7 @@ import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFact
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
parsePortalSession,
requireSession,
setPortalSession,
} from '../middleware/session.js'
@@ -29,7 +30,15 @@ import {
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 {
VIEW_EXPIRED_MESSAGE,
endKundenansicht,
isExpired,
isKundenansicht,
logOnce,
setKundenansichtPreview,
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'
@@ -265,6 +274,15 @@ router.post('/logout', (_req, 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) {
@@ -393,6 +411,37 @@ router.get('/me', async (req, res) => {
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),
})
}
// 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
+21
View File
@@ -1,11 +1,32 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { listViewLog } from '../../services/customerViewLog.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()
// Portal-Zugang und letzter Login (Karte "Portal-Zugang" im Ticketsystem)
router.get('/:id/portal', async (req, res) => {
try {
const { customer, hasAccess, lastLoginAt } = await getPortalAccessStatus(req.params.id)
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' })
return res.json({ hasAccess, lastLoginAt })
} catch (err) {
return res.status(500).json({ error: err.message || 'Portal-Zugang konnte nicht geladen werden' })
}
})
// Protokoll der Kundenansicht (Reiter "Protokoll"), neueste zuerst
router.get('/:id/protokoll', async (req, res) => {
try {
return res.json({ entries: await listViewLog(req.params.id) })
} catch (err) {
return res.status(500).json({ error: err.message || 'Protokoll konnte nicht geladen werden' })
}
})
// Kundenansicht oeffnen: Einmal-Link (60 s), den das Ticketsystem in einem neuen Tab aufruft
router.post('/:id/kundenansicht', async (req, res) => {
try {
+2
View File
@@ -1,10 +1,12 @@
import { Router } from 'express'
import { requireStaffJwt } from '../../middleware/staffJwt.js'
import customersRoutes from './customers.js'
import previewRoutes from './preview.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)
router.use('/preview-link', previewRoutes)
export default router
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express'
import { buildStaffPreviewLink } from '../admin/previewOpen.js'
// Preview aus dem Ticketsystem oeffnen (alle Mitarbeiter): Einmal-Link statt Mitarbeiter-Login im Portal
const router = Router()
router.post('/', async (req, res) => {
const { status, body } = await buildStaffPreviewLink({
userId: req.staff.appwriteUserId,
email: req.staff.email,
name: req.staff.name,
subdomain: req.body?.subdomain,
previewUrl: req.body?.previewUrl,
})
return res.status(status).json(body)
})
export default router
+1
View File
@@ -14,6 +14,7 @@ import {
* 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 VIEW_EXPIRED_MESSAGE = 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.'
export const isKundenansicht = (session) => Boolean(session?.viewAs && session.viewId)