From 1cd776a2ee4b1ba2c47f4cab3dd7211f4abe7675 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:27:21 +0000 Subject: [PATCH] Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll Co-Authored-By: Claude Opus 5.5 --- server/middleware/session.js | 4 +- server/routes/auth.js | 25 +++++++ server/routes/team/customers.js | 22 ++++++ server/routes/team/index.js | 2 + server/services/kundenansicht.js | 80 ++++++++++++++++++++ server/services/portalAccessStatus.js | 20 +++++ server/services/previewSession.js | 9 ++- server/services/staffPreviewTokens.js | 3 +- test/kundenansicht.test.js | 103 ++++++++++++++++++++++++++ test/support/fixtures.js | 16 ++++ 10 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 server/routes/team/customers.js create mode 100644 server/services/kundenansicht.js create mode 100644 server/services/portalAccessStatus.js create mode 100644 test/kundenansicht.test.js diff --git a/server/middleware/session.js b/server/middleware/session.js index 83e12ca..8f0ced1 100644 --- a/server/middleware/session.js +++ b/server/middleware/session.js @@ -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, }) } diff --git a/server/routes/auth.js b/server/routes/auth.js index 12597dd..c70bdda 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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. diff --git a/server/routes/team/customers.js b/server/routes/team/customers.js new file mode 100644 index 0000000..09c20de --- /dev/null +++ b/server/routes/team/customers.js @@ -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 diff --git a/server/routes/team/index.js b/server/routes/team/index.js index 883a829..6ef6b0e 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -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 diff --git a/server/services/kundenansicht.js b/server/services/kundenansicht.js new file mode 100644 index 0000000..4186a3b --- /dev/null +++ b/server/services/kundenansicht.js @@ -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, + }) +} diff --git a/server/services/portalAccessStatus.js b/server/services/portalAccessStatus.js new file mode 100644 index 0000000..f5d8a06 --- /dev/null +++ b/server/services/portalAccessStatus.js @@ -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, + } +} diff --git a/server/services/previewSession.js b/server/services/previewSession.js index af1c9b5..f4db51c 100644 --- a/server/services/previewSession.js +++ b/server/services/previewSession.js @@ -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, } } diff --git a/server/services/staffPreviewTokens.js b/server/services/staffPreviewTokens.js index 888f2ef..4c58feb 100644 --- a/server/services/staffPreviewTokens.js +++ b/server/services/staffPreviewTokens.js @@ -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() diff --git a/test/kundenansicht.test.js b/test/kundenansicht.test.js new file mode 100644 index 0000000..6103897 --- /dev/null +++ b/test/kundenansicht.test.js @@ -0,0 +1,103 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Kundenansicht aus dem Ticketsystem: Einmal-Link (60 s) -> Portal-Sitzung des Kunden fuer 2 Stunden +const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post, rawGet } = await import('./support/testApp.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { default: authRoutes } = await import('../server/routes/auth.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js') +const { verifyPreviewSessionToken } = await import('../server/services/previewSession.js') +const app = await startTestApp({ '/api/auth': authRoutes, '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true, lastLoginAt: '2026-09-30T10:00:00.000Z' }) +aw.seed('customerPortalAccess', { $id: 'pa3', customerId: 'c3', enabled: false }) + +const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } }) +const link = (customerId, jwt = 'jwt-staff') => post(`/api/team/customers/${customerId}/kundenansicht`, {}, bearer(jwt)) +const tokenFor = async (customerId) => new URL((await link(customerId)).body.openUrl).searchParams.get('token') +const setCookie = (res, name) => res.headers.getSetCookie().find((c) => c.startsWith(`${name}=`)) || null +const cookieValue = (res, name) => { + const c = setCookie(res, name) + return c ? decodeURIComponent(c.split(';')[0].slice(name.length + 1)) : null +} +const portalSession = (res) => { + const raw = cookieValue(res, config.cookieName) + return raw ? JSON.parse(raw.slice(2, raw.lastIndexOf('.'))) : null +} +const logEntries = () => [...aw.docs('customerViewLog').values()] + +test('Link fuer Mitarbeiter: Einmal-Link auf /api/auth/kundenansicht', async () => { + const res = await link('c1') + assert.equal(res.status, 200) + assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/kundenansicht\?token=[0-9a-f]{64}$/) +}) + +test('Link ohne JWT 401, fuer Kunden-JWT 403', async () => { + assert.equal((await post('/api/team/customers/c1/kundenansicht', {})).status, 401) + assert.equal((await link('c1', 'jwt-kunde')).status, 403) +}) + +test('unbekannter Kunde 404, Kunde ohne Portal-Zugang 409 no_portal_access', async () => { + assert.equal((await link('gibtsnicht')).status, 404) + for (const id of ['c2', 'c3']) { + const res = await link(id) + assert.equal(res.status, 409, id) + assert.equal(res.body.code, 'no_portal_access', id) + } +}) + +test('Einloesen: Sitzung 2 h, Preview-Cookie nur fuer den Kunden, Protokoll geoeffnet, Weiter zum Dashboard', async () => { + const before = logEntries().length + const res = await rawGet(`/api/auth/kundenansicht?token=${await tokenFor('c1')}`) + assert.equal(res.status, 302) + assert.equal(res.headers.get('location'), '/dashboard.html') + const s = portalSession(res) + assert.equal(s.viewAs, true) + assert.equal(s.role, 'admin') + assert.match(s.viewId, /^[0-9a-f]{32}$/) + assert.ok(Math.abs(s.viewExpiresAt - (Date.now() + 7200000)) < 5000) + assert.deepEqual([s.customerId, s.adminName, s.appwriteUserId, s.name], ['c1', 'Andrej', 'u_staff2', 'Kunde Eins']) + assert.match(setCookie(res, config.cookieName), /Max-Age=7200/) + const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session')) + assert.deepEqual([preview.role, preview.customerId, preview.userId], ['customer', 'c1', 'u_staff2']) + assert.ok(preview.exp - preview.iat <= 7200) + assert.match(setCookie(res, 'preview_session'), /Max-Age=7200/) + const all = logEntries() + assert.equal(all.length, before + 1) + const log = all.at(-1) + assert.deepEqual([log.customerId, log.action, log.staffName, log.staffUserId], ['c1', 'geoeffnet', 'Andrej', 'u_staff2']) +}) + +test('Token zweimal -> 410 mit Hinweis, keine Sitzung', async () => { + const path = `/api/auth/kundenansicht?token=${await tokenFor('c1')}` + assert.equal((await rawGet(path)).status, 302) + const again = await rawGet(path) + assert.equal(again.status, 410) + assert.match(again.body, /im Ticketsystem erneut öffnen/) + assert.equal(portalSession(again), null) +}) + +test('Token nach 61 s -> 410', async (t) => { + const token = await tokenFor('c1') + const now = Date.now() + t.mock.method(Date, 'now', () => now + 61 * 1000) + assert.equal((await rawGet(`/api/auth/kundenansicht?token=${token}`)).status, 410) +}) + +test('Preview-Token gilt hier nicht, Kundenansicht-Token nicht als Preview-Link', async () => { + const previewToken = createStaffPreviewToken({ userId: 'u_admin', email: 'kenso@webklar.com', name: 'Kenso', previewUrl: 'https://x.project.webklar.com/' }) + assert.equal((await rawGet(`/api/auth/kundenansicht?token=${previewToken}`)).status, 410) + assert.equal((await rawGet(`/api/auth/staff-preview?token=${await tokenFor('c1')}`)).status, 410) +}) diff --git a/test/support/fixtures.js b/test/support/fixtures.js index 4d7381a..b59ccaf 100644 --- a/test/support/fixtures.js +++ b/test/support/fixtures.js @@ -110,6 +110,22 @@ export const viewAsCookie = (customerId = 'c1') => email: customers[customerId]?.email || '', }) +/** Kundenansicht aus dem Ticketsystem (Einmal-Link, 2 h, Kennung viewId): Mitarbeiter Andrej ohne Admin-Label. */ +export const kundenansichtCookie = ({ customerId = 'c1', viewId = 'a'.repeat(32), viewExpiresAt = Date.now() + 3600000 } = {}) => + sessionCookie({ + role: 'admin', + viewAs: true, + viewId, + viewExpiresAt, + isAdmin: false, + appwriteUserId: 'u_staff2', + adminName: 'Andrej', + adminEmail: 'andrej@webklar.com', + customerId, + name: customers[customerId]?.name || '', + email: customers[customerId]?.email || '', + }) + /** Kunden c1–c4 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */ export function seedFixtures(aw) { for (const customer of Object.values(customers)) aw.seed('customers', customer)