diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index 4a5d0cd..9f437ca 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -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) diff --git a/server/routes/admin/previewOpen.js b/server/routes/admin/previewOpen.js index 24286bb..3b18ef3 100644 --- a/server/routes/admin/previewOpen.js +++ b/server/routes/admin/previewOpen.js @@ -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 diff --git a/server/routes/auth.js b/server/routes/auth.js index c70bdda..d6c4489 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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 diff --git a/server/routes/team/customers.js b/server/routes/team/customers.js index 09c20de..9185aac 100644 --- a/server/routes/team/customers.js +++ b/server/routes/team/customers.js @@ -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 { diff --git a/server/routes/team/index.js b/server/routes/team/index.js index 6ef6b0e..d28d893 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -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 diff --git a/server/routes/team/preview.js b/server/routes/team/preview.js new file mode 100644 index 0000000..c172ad6 --- /dev/null +++ b/server/routes/team/preview.js @@ -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 diff --git a/server/services/kundenansicht.js b/server/services/kundenansicht.js index 0cf957c..f8b6cee 100644 --- a/server/services/kundenansicht.js +++ b/server/services/kundenansicht.js @@ -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) diff --git a/test/kundenansichtEnde.test.js b/test/kundenansichtEnde.test.js new file mode 100644 index 0000000..746b81d --- /dev/null +++ b/test/kundenansichtEnde.test.js @@ -0,0 +1,89 @@ +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 beenden (Rueckweg ins Ticketsystem) und /api/auth/me in der Kundenansicht +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, get, post } = await import('./support/testApp.js') +const { seedFixtures, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { default: authRoutes } = await import('../server/routes/auth.js') +const { verifyPreviewSessionToken } = await import('../server/services/previewSession.js') +const app = await startTestApp({ '/': kundenansichtGuard, '/api/auth': authRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) + +const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || 'a'.repeat(32) } }) +const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c)) +const cookieValue = (res, name) => { + const c = res.headers.getSetCookie().find((x) => x.startsWith(`${name}=`)) + return c ? decodeURIComponent(c.split(';')[0].slice(name.length + 1)) : null +} +const logOf = (action) => [...aw.docs('customerViewLog').values()].filter((d) => d.action === action) +const me = async (cookie) => { + const res = await fetch(`${app.url}/api/auth/me`, { headers: { cookie } }) + return { status: res.status, body: await res.json(), headers: res.headers } +} + +test('Beenden: Protokoll beendet, beide Cookies geloescht, Rueckweg zur Kundenseite im WOMS', async () => { + const before = logOf('beendet').length + const res = await post('/api/auth/kundenansicht/ende', {}, kv({ viewId: 'b'.repeat(32) })) + assert.equal(res.status, 200) + assert.equal(res.body.redirect, 'https://ticket.webklar.com/customers/c1') + assert.ok(clears(res, config.cookieName)) + assert.ok(clears(res, 'preview_session')) + const ended = logOf('beendet') + assert.equal(ended.length, before + 1) + assert.deepEqual([ended.at(-1).customerId, ended.at(-1).staffName], ['c1', 'Andrej']) +}) + +test('Beenden ohne Kundenansicht -> Rueckweg /customers, kein Protokoll', async () => { + const before = logOf('beendet').length + const res = await post('/api/auth/kundenansicht/ende', {}) + assert.equal(res.status, 200) + assert.equal(res.body.redirect, 'https://ticket.webklar.com/customers') + assert.equal(logOf('beendet').length, before) +}) + +test('/me in neuer Ansicht: viewId, viewExpiresAt, admin.name, Preview-Cookie nur fuer den Kunden', async () => { + const viewExpiresAt = Date.now() + 30 * 60 * 1000 + const res = await me(kundenansichtCookie({ viewId: 'c'.repeat(32), viewExpiresAt })) + assert.equal(res.status, 200) + assert.equal(res.body.authenticated, true) + assert.equal(res.body.viewAs, true) + assert.equal(res.body.viewId, 'c'.repeat(32)) + assert.equal(res.body.viewExpiresAt, viewExpiresAt) + assert.equal(res.body.admin.name, 'Andrej') + assert.equal(res.body.customer.id, 'c1') + const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session')) + assert.deepEqual([preview.role, preview.customerId], ['customer', 'c1']) + assert.ok(preview.exp <= Math.ceil(viewExpiresAt / 1000) + 1, 'Preview endet mit der Ansicht') +}) + +test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abgelaufen einmal', async () => { + const before = logOf('abgelaufen').length + const cookie = kundenansichtCookie({ viewId: 'd'.repeat(32), viewExpiresAt: Date.now() - 1000 }) + for (let i = 0; i < 2; i += 1) { + const res = await me(cookie) + assert.equal(res.status, 200) + assert.equal(res.body.authenticated, false) + assert.equal(res.body.reason, 'view_expired') + assert.equal(res.body.message, 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.') + assert.ok(clears(res, config.cookieName)) + } + assert.equal(logOf('abgelaufen').length, before + 1) +}) + +test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => { + const body = await get('/api/auth/me', { cookie: viewAsCookie('c1') }) + assert.equal(body.viewAs, true) + assert.equal(body.viewId, undefined) + assert.equal(body.admin.name, 'Kenso') +}) diff --git a/test/teamRoutes.test.js b/test/teamRoutes.test.js new file mode 100644 index 0000000..17c5b9f --- /dev/null +++ b/test/teamRoutes.test.js @@ -0,0 +1,69 @@ +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' + +// Team-Routen fuers Ticketsystem: Portal-Status, Protokoll der Kundenansicht, Preview-Link fuer alle Mitarbeiter +const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, rawGet, post } = await import('./support/testApp.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const { consumeStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js') +const app = await startTestApp({ '/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('customerViewLog', { $id: 'v1', customerId: 'c1', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'geoeffnet', detail: '', createdAt: '2026-09-30T10:00:00.000Z' }) +aw.seed('customerViewLog', { $id: 'v2', customerId: 'c1', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'news_angelegt', detail: 'Hallo', createdAt: '2026-09-30T10:05:00.000Z' }) +aw.seed('customerViewLog', { $id: 'v3', customerId: 'c2', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'geoeffnet', detail: '', createdAt: '2026-09-30T11:00:00.000Z' }) +aw.seed('websiteProjects', { $id: 'wp1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', status: 'ready' }) +aw.seed('websiteProjects', { $id: 'wp2', customerId: 'c1', projectName: 'Café', subdomain: 'cafe', status: 'building' }) + +const auth = { headers: { authorization: 'Bearer jwt-staff' } } + +test('portal: hasAccess und lastLoginAt; c2 ohne Zugang hasAccess false; unbekannt 404', async () => { + const c1 = await rawGet('/api/team/customers/c1/portal', auth) + assert.equal(c1.status, 200) + assert.deepEqual(c1.body, { hasAccess: true, lastLoginAt: '2026-09-30T10:00:00.000Z' }) + assert.deepEqual((await rawGet('/api/team/customers/c2/portal', auth)).body, { hasAccess: false, lastLoginAt: null }) + assert.equal((await rawGet('/api/team/customers/gibtsnicht/portal', auth)).status, 404) +}) + +test('protokoll: Eintraege des Kunden neueste zuerst', async () => { + const res = await rawGet('/api/team/customers/c1/protokoll', auth) + assert.equal(res.status, 200) + assert.deepEqual(res.body.entries.map((e) => e.id), ['v2', 'v1']) + assert.deepEqual(res.body.entries[0], { id: 'v2', at: '2026-09-30T10:05:00.000Z', staffName: 'Andrej', action: 'news_angelegt', detail: 'Hallo' }) +}) + +test('preview-link fuer Mitarbeiter ohne Admin-Label -> openUrl auf staff-preview', async () => { + const res = await post('/api/team/preview-link', { subdomain: 'baeckerei', previewUrl: 'https://baeckerei.project.webklar.com/' }, auth) + assert.equal(res.status, 200) + assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-preview\?token=[0-9a-f]{64}$/) + const data = consumeStaffPreviewToken(new URL(res.body.openUrl).searchParams.get('token')) + assert.deepEqual([data.userId, data.name, data.previewUrl, data.customerId], ['u_staff2', 'Andrej', 'https://baeckerei.project.webklar.com/', 'c1']) +}) + +test('preview-link: nicht bereit 409, unbekannt 404, ohne Subdomain 400', async () => { + assert.equal((await post('/api/team/preview-link', { subdomain: 'cafe' }, auth)).status, 409) + assert.equal((await post('/api/team/preview-link', { subdomain: 'gibtsnicht' }, auth)).status, 404) + assert.equal((await post('/api/team/preview-link', {}, auth)).status, 400) +}) + +test('alle Team-Routen ohne JWT -> 401', async () => { + for (const [method, p] of [ + ['GET', '/api/team/customers/c1/portal'], + ['GET', '/api/team/customers/c1/protokoll'], + ['POST', '/api/team/customers/c1/kundenansicht'], + ['POST', '/api/team/preview-link'], + ]) { + const res = method === 'GET' ? await rawGet(p) : await post(p, {}) + assert.equal(res.status, 401, `${method} ${p}`) + } +})