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:
1 parent
7d8ba56088
commit
c5afd42a5f
9 files changed
+284
-23
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { parsePortalSession } from './session.js'
|
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):
|
* 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)) {
|
if (current && isExpired(session)) {
|
||||||
endKundenansicht(res)
|
endKundenansicht(res)
|
||||||
await logOnce(session, 'abgelaufen')
|
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')
|
const header = req.get('X-Kundenansicht')
|
||||||
if (current && header && header !== session.viewId) return replaced(res, session)
|
if (current && header && header !== session.viewId) return replaced(res, session)
|
||||||
|
|||||||
@@ -35,21 +35,18 @@ function previewUrlForSubdomain(subdomain) {
|
|||||||
return `https://${safe}.${config.preview.baseHost}`
|
return `https://${safe}.${config.preview.baseHost}`
|
||||||
}
|
}
|
||||||
|
|
||||||
router.use(requireAppwriteStaff)
|
/**
|
||||||
|
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview). Genutzt von dieser
|
||||||
router.post('/', async (req, res) => {
|
* Admin-Route und vom Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
|
||||||
const user = req.appwriteUser
|
*/
|
||||||
if (!isStaffAdmin(user)) {
|
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
|
||||||
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
|
const subdomain = String(rawSubdomain || '')
|
||||||
}
|
|
||||||
|
|
||||||
const subdomain = String(req.body?.subdomain || '')
|
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
const previewUrl = safePreviewUrl(req.body?.previewUrl) || previewUrlForSubdomain(subdomain)
|
const previewUrl = safePreviewUrl(rawPreviewUrl) || previewUrlForSubdomain(subdomain)
|
||||||
|
|
||||||
if (!subdomain || !previewUrl) {
|
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 {
|
try {
|
||||||
@@ -59,27 +56,42 @@ router.post('/', async (req, res) => {
|
|||||||
])
|
])
|
||||||
const project = projects[0]
|
const project = projects[0]
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
return { status: 404, body: { error: 'Projekt nicht gefunden' } }
|
||||||
}
|
}
|
||||||
if (!isPreviewProjectReady(project)) {
|
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({
|
const token = createStaffPreviewToken({
|
||||||
userId: user.$id,
|
userId,
|
||||||
email: user.email || '',
|
email: email || '',
|
||||||
name: user.name || user.email || 'Admin',
|
name: name || email || 'Admin',
|
||||||
previewUrl,
|
previewUrl,
|
||||||
customerId: project.customerId || '',
|
customerId: project.customerId || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const portalBase = `https://${config.preview.baseHost}`
|
const portalBase = `https://${config.preview.baseHost}`
|
||||||
return res.json({
|
return { status: 200, body: { openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}` } }
|
||||||
openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}`,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} 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
|
export default router
|
||||||
+50
-1
@@ -14,6 +14,7 @@ import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFact
|
|||||||
import {
|
import {
|
||||||
SESSION_REVOKED_MESSAGE,
|
SESSION_REVOKED_MESSAGE,
|
||||||
clearPortalSession,
|
clearPortalSession,
|
||||||
|
parsePortalSession,
|
||||||
requireSession,
|
requireSession,
|
||||||
setPortalSession,
|
setPortalSession,
|
||||||
} from '../middleware/session.js'
|
} from '../middleware/session.js'
|
||||||
@@ -29,7 +30,15 @@ import {
|
|||||||
import { isPreviewProjectReady } from '../services/previewReadiness.js'
|
import { isPreviewProjectReady } from '../services/previewReadiness.js'
|
||||||
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
|
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
|
||||||
import { getPortalAccessStatus } from '../services/portalAccessStatus.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 { logViewAction } from '../services/customerViewLog.js'
|
||||||
import { resolveStaffAccount } from '../services/staffAccount.js'
|
import { resolveStaffAccount } from '../services/staffAccount.js'
|
||||||
import { MFA_COOKIE, MFA_TTL_MS, resendStaffMfa, startStaffMfa, verifyStaffMfa } from '../services/staffMfa.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 })
|
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) => {
|
router.get('/preview-access', async (req, res) => {
|
||||||
const token = req.cookies?.[PREVIEW_COOKIE_NAME]
|
const token = req.cookies?.[PREVIEW_COOKIE_NAME]
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -393,6 +411,37 @@ router.get('/me', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const session = JSON.parse(raw)
|
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
|
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
|
||||||
if (session.role === 'admin' && session.appwriteUserId) {
|
if (session.role === 'admin' && session.appwriteUserId) {
|
||||||
// Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die
|
// Sessions von vor der Mitarbeiter-Freigabe kennen das Flag nicht - die
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
|
import { listViewLog } from '../../services/customerViewLog.js'
|
||||||
import { getPortalAccessStatus } from '../../services/portalAccessStatus.js'
|
import { getPortalAccessStatus } from '../../services/portalAccessStatus.js'
|
||||||
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
|
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
|
||||||
|
|
||||||
// Kunden im Team-Eingang (/api/team/customers), requireStaffJwt sitzt in routes/team/index.js
|
// Kunden im Team-Eingang (/api/team/customers), requireStaffJwt sitzt in routes/team/index.js
|
||||||
const router = Router()
|
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
|
// Kundenansicht oeffnen: Einmal-Link (60 s), den das Ticketsystem in einem neuen Tab aufruft
|
||||||
router.post('/:id/kundenansicht', async (req, res) => {
|
router.post('/:id/kundenansicht', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { requireStaffJwt } from '../../middleware/staffJwt.js'
|
import { requireStaffJwt } from '../../middleware/staffJwt.js'
|
||||||
import customersRoutes from './customers.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)
|
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(requireStaffJwt)
|
router.use(requireStaffJwt)
|
||||||
router.use('/customers', customersRoutes)
|
router.use('/customers', customersRoutes)
|
||||||
|
router.use('/preview-link', previewRoutes)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -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
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
|
* 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 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)
|
export const isKundenansicht = (session) => Boolean(session?.viewAs && session.viewId)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user