Compare commits

...
11 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 a64a996291 Kundenansicht: Waechter-Korrekturen aus dem Abschlussreview
- Pfade ohne Ruecksicht auf Gross-/Kleinschreibung pruefen (/API/news umging die Sperre)
- Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten
- Cookie lebt 1 h laenger als die Ansicht, damit 'abgelaufen' gemeldet und protokolliert wird

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:54:50 +00:00
ClaudeandClaude Opus 5.5 0ccdde1c28 Datenschutz Abschnitt 6: Kundenansicht mit News, Sperren und Protokoll (12 Monate)
Stand 01.10.2026. Der Test koppelte den Stand bisher an termsVersion; jetzt
genuegt 'nicht vor termsVersion', weil die Erklaerung unabhaengig geaendert wird.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:38:14 +00:00
ClaudeandClaude Opus 5.5 c1fda8a717 Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:55 +00:00
ClaudeandClaude Opus 5.5 c5afd42a5f 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>
2026-10-01 19:32:59 +00:00
ClaudeandClaude Opus 5.5 7d8ba56088 Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:30:47 +00:00
ClaudeandClaude Opus 5.5 25f3c1df4d Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:29:47 +00:00
ClaudeandClaude Opus 5.5 1cd776a2ee Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:27:21 +00:00
ClaudeandClaude Opus 5.5 fdf594706d Kundenansicht: Protokoll-Collection customerViewLog mit Schreiben, Lesen und Loeschen nach 12 Monaten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:25:26 +00:00
ClaudeandClaude Opus 5.5 772095ab19 Team-Eingang: /api/team mit Appwrite-JWT fuer alle Mitarbeiter, CORS-Header fuer Uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:23:57 +00:00
ClaudeandClaude Opus 5.5 be65baff01 Plan: Admin-Umzug Etappe 1 (Fundament + Kundenansicht)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:19:57 +00:00
ClaudeandClaude Opus 5.5 5fb39f1a1b Spec: Admin-Panel ins Ticketsystem (Kundenansicht, Protokoll, Team-Schnittstelle, 4 Etappen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:11:32 +00:00
41 changed files with 2705 additions and 77 deletions

No files matched your search

Binary file not shown.
@@ -0,0 +1,649 @@
# Admin-Panel ins Ticketsystem – Etappe 1 (Fundament + Kundenansicht) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mitarbeiter öffnen aus dem WOMS mit einem Klick die Kundenansicht im Portal (nur News schreibbar, 2 h, Verwechslungsschutz, protokolliert); das Portal bekommt den JWT-Eingang `/api/team` für alle Mitarbeiter; Previews laufen über Einmal-Links.
**Architecture:** Portal-Server (Express, ESM, Node 20) bekommt `requireStaffJwt` + Router `/api/team`, einen zentralen Kundenansicht-Wächter vor allen Kunden-Routen und eine Protokoll-Collection `customerViewLog`. Das Dashboard markiert jede API-Anfrage mit der Ansichts-Kennung. WOMS (React 18, react-router 6) bekommt einen `teamApi`-Client, den Knopf „Kundenansicht öffnen“, Tab in der URL, Protokoll-Tab und Preview-Knöpfe per Einmal-Link. Das Admin-Panel bleibt unverändert in Betrieb.
**Tech Stack:** Portal: Express 4, cookie-parser (signierte Cookies), jose, node:test mit `test/support/*` (Appwrite-Nachbau, Stripe-Attrappe). WOMS: Vite 5, React 18, appwrite 13, node:test + esbuild-Loader `tests/unit/load.mjs`.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitte 1, 3, 4 und Etappe 1 in Abschnitt 5)
**Abweichung von der Spec (bewusst):** Die Route `GET /api/team/customers/:id/abo` wandert nach Etappe 3, wo ihr einziger Nutzer (Tab „Abo & Angebote“) entsteht. In Etappe 1 hätte sie keinen Aufrufer.
## Global Constraints
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, `node_modules` ist ein Symlink auf `../build/node_modules`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug` von `origin/test`, wird in Task 9 angelegt).
- **Nie `git add -A`** (Portal: `node_modules`-Symlink ist untracked; WOMS: `node_modules` ist getrackt). Immer einzelne Dateien adden.
- **Nichts pushen.** WOMS deployt jeden gepushten Branch. Livegang macht der Nutzer.
- `grep` in dieser Shell ist ein Wrapper, der Dateien still überspringt: `command grep -a` benutzen.
- UI-Texte deutsch mit Umlauten; Code-Kommentare im Portal wie im Bestand (ASCII, „ae/oe/ue“).
- Kundenansicht-Laufzeit: **2 Stunden** (`7200000` ms). Einmal-Link: **60 s**, einmal einlösbar. Protokoll-Aufbewahrung: **12 Monate** (365 Tage).
- Header-Name: `X-Kundenansicht`. Fehlercodes: `view_expired` (401), `view_replaced` (409), `view_blocked` (403), `no_portal_access` (409).
- Protokoll-Aktionen (genau diese Strings): `geoeffnet`, `beendet`, `abgelaufen`, `news_angelegt`, `news_bild`, `news_geaendert`, `news_geloescht`.
- Banner-Text: „Kundenansicht: *Kunde* · geöffnet von *Mitarbeiter*“ + Knopf „Beenden“.
- Rückweg nach „Beenden“: `${config.womsBaseUrl}/customers/<customerId>` (`WOMS_PUBLIC_URL`, Standard `https://ticket.webklar.com`).
- Portal-Tests: `npm test` im Portal-Worktree (Node 24 auf dem Host reicht für die Tests). WOMS-Tests: `npm test` im WOMS-Worktree.
## Review Focus
1. **Zwei Tabs, zwei Kunden:** Tab A (Kunde A) fragt weiter ab, nachdem im selben Browser Kunde B geöffnet wurde. Erwartung: Tab A bekommt 409 und zeigt nur den Ersetzt-Hinweis, nie Daten von B (Task 4 Server, Task 7 Client).
2. **Kunden-JWT am Team-Eingang:** Kunden haben Appwrite-Konten im selben Projekt und können sich ein JWT holen. Erwartung: 403, auch wenn die (unbestätigte) Konto-Mail einer Mitarbeiter-Mail gleicht (Task 1).
3. **Grenze der 2 Stunden:** Anfrage genau bei `viewExpiresAt`. Erwartung: abgelaufen (Task 4).
4. **Alte Kundenansicht aus admin.html** (Sitzung ohne `viewId`) während Etappe 1–3. Erwartung: Lesen geht, jede Schreibaktion 403, „Zurück zur Admin-Übersicht“ funktioniert weiter (Task 4, Task 7).
5. **Pop-up-Sperre:** Der neue Tab wird erst nach einer Netzwerkanfrage befüllt. Erwartung: `window.open` läuft synchron im Klick, Fehler schließen den leeren Tab wieder (Task 9).
---
## Portal
### Task 1: JWT-Eingang `/api/team` für alle Mitarbeiter + CORS-Header
**Files:**
- Create: `server/middleware/staffJwt.js`
- Create: `server/middleware/cors.js`
- Create: `server/routes/team/index.js`
- Modify: `server/index.js:55-69` (CORS-Block durch `corsMiddleware` ersetzen; `app.use('/api/team', teamRoutes)` nach `/api/portal-admin` einhängen)
- Modify: `server/routes/admin/switchLink.js` (lokales `appwriteAccountFromJwt` durch Import aus `staffJwt.js` ersetzen)
- Modify: `test/support/appwriteMock.js:167-178` (zwei neue Test-JWTs)
- Test: `test/teamAuth.test.js`
**Interfaces:**
- Produces: `appwriteAccountFromJwt(req) -> Promise<object|null>` (Appwrite `/account` mit `X-Appwrite-JWT`; null bei fehlendem Header oder nicht-ok).
- Produces: `resolveTeamStaff(account) -> Promise<{ isAdmin, name, email } | null>`.
- Produces: `requireStaffJwt(req, res, next)`; setzt `req.session = { role: 'admin', isAdmin, appwriteUserId, name, email }` (gleiche Form wie die Admin-Sitzung, damit bestehende Handler unverändert laufen) und `req.staff` = dasselbe Objekt.
- Produces: `corsMiddleware(allowedOrigins: string[])`.
- Produces: `server/routes/team/index.js` default export `Router` mit `router.use(requireStaffJwt)`; spätere Tasks hängen Unter-Router ein.
- [ ] **Step 1: Test-JWTs im Appwrite-Nachbau ergänzen**
In `/v1/account` zwei Fälle vor dem Session-Fall:
`'jwt-mail-ungeprueft'` → `[200, { $id: 'u_fremd', email: 'andrej@webklar.com', emailVerification: false, labels: [] }]`,
`'jwt-mail-geprueft'` → `[200, { $id: 'u_neu', email: 'nico@webklar.com', emailVerification: true, name: 'Nico', labels: [] }]`.
- [ ] **Step 2: Failing tests schreiben** (`test/teamAuth.test.js`, Aufbau wie `test/staffSwitch.test.js`; Probe-Router `GET /probe` hinter `requireStaffJwt`, der `req.session` als JSON zurückgibt)
```js
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('employees', { $id: 'e_nico', userId: '', displayName: 'Nico', email: 'nico@webklar.com' })
test('ohne, ungueltiges und halbes JWT -> 401', async () => {
for (const jwt of [null, 'jwt-falsch', 'jwt-halb']) assert.equal((await probe(jwt)).status, 401, String(jwt))
})
test('Kunde (kein Mitarbeiter, kein Label) -> 403', async () => {
assert.equal((await probe('jwt-kunde')).status, 403)
})
test('Mail gleicht Mitarbeiter, ist aber unbestaetigt -> 403', async () => {
assert.equal((await probe('jwt-mail-ungeprueft')).status, 403)
})
test('bestaetigte Mail eines Mitarbeiters ohne userId -> erlaubt', async () => {
const res = await probe('jwt-mail-geprueft')
assert.equal(res.status, 200)
assert.equal(res.body.name, 'Nico')
})
test('Mitarbeiter ohne Admin-Label -> erlaubt, Sitzung in Admin-Form', async () => {
const res = await probe('jwt-staff')
assert.deepEqual(res.body, { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' })
})
test('Label admin ohne employees-Eintrag -> erlaubt, isAdmin true', async () => {
const res = await probe('jwt-admin')
assert.equal(res.status, 200)
assert.equal(res.body.isAdmin, true)
})
test('CORS: erlaubte Herkunft bekommt die Upload-Header, fremde nichts', async () => {
// corsMiddleware(['https://ticket.webklar.com']) vor dem Probe-Router
const ok = await rawGet('/probe', { headers: { origin: 'https://ticket.webklar.com' } })
assert.equal(ok.headers.get('access-control-allow-headers'),
'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height')
const bad = await rawGet('/probe', { headers: { origin: 'https://evil.example' } })
assert.equal(bad.headers.get('access-control-allow-origin'), null)
})
```
- [ ] **Step 3: Tests laufen lassen, Fehlschlag prüfen**
Run: `node --test test/teamAuth.test.js`
Expected: FAIL (Modul `server/middleware/staffJwt.js` fehlt)
- [ ] **Step 4: Implementieren**
`resolveTeamStaff`: Mitarbeiter, wenn (a) `getEmployeeByUserId(account.$id)` trifft, oder (b) `account.labels` enthält `'admin'`, oder (c) `account.emailVerification === true` und `getEmployeeByEmail(account.email)` trifft. Name = `employee.displayName || account.name || 'Mitarbeiter'`, E-Mail = `account.email || employee.email`. Fehlender Header oder nicht-ok `/account` → 401 `{ error: 'Bitte im Ticketsystem neu anmelden.' }`; kein Mitarbeiter → 403 `{ error: 'Kein Mitarbeiterkonto.' }`; Ausnahme → 500. `corsMiddleware` übernimmt das bisherige Verhalten aus `index.js` (Methoden `GET, POST, PATCH, DELETE, OPTIONS`, OPTIONS → 204) mit der neuen Header-Liste aus dem Test. `switchLink.js` nutzt `appwriteAccountFromJwt` aus `staffJwt.js`; sein Verhalten bleibt (bestehende `test/staffSwitch.test.js` müssen grün bleiben).
- [ ] **Step 5: Tests laufen lassen**
Run: `node --test test/teamAuth.test.js test/staffSwitch.test.js`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add server/middleware/staffJwt.js server/middleware/cors.js server/routes/team/index.js server/index.js server/routes/admin/switchLink.js test/support/appwriteMock.js test/teamAuth.test.js
git commit -m "Team-Eingang: /api/team mit Appwrite-JWT fuer alle Mitarbeiter, CORS-Header fuer Uploads"
```
### Task 2: Protokoll-Collection `customerViewLog` und Dienst
**Files:**
- Modify: `scripts/setup-abo-collections.mjs` (neue `ensureCollection` vor `console.log('Fertig.')`; Kopfkommentar ergänzen)
- Modify: `server/config.js:141-161` (`customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog'`)
- Create: `server/services/customerViewLog.js`
- Modify: `server/index.js` (täglicher Aufräumlauf neben den anderen Timern)
- Test: `test/customerViewLog.test.js`
**Interfaces:**
- Produces: `VIEW_ACTIONS` (Array der 7 Aktions-Strings aus den Global Constraints).
- Produces: `logViewAction({ customerId, staffUserId, staffName, action, detail = '' }) -> Promise<void>`; wirft nie (Fehler nur `console.warn('[kundenansicht] Protokoll ...')`); kürzt `detail` auf 300 Zeichen; setzt `createdAt` = jetzt (ISO).
- Produces: `listViewLog(customerId, { limit = 200 } = {}) -> Promise<Array<{ id, at, staffName, action, detail }>>`, neueste zuerst.
- Produces: `purgeViewLog({ now = new Date() } = {}) -> Promise<{ deleted: number }>`; löscht Einträge mit `createdAt` älter als 365 Tage, seitenweise (je 100) bis keine mehr übrig sind.
- [ ] **Step 1: Collection im Setup-Skript anlegen**
```js
await ensureCollection(
'customerViewLog',
'Kundenansicht-Protokoll',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'staffUserId', size: 64 },
{ type: 'string', key: 'staffName', size: 128 },
{ type: 'string', key: 'action', size: 32 },
{ type: 'string', key: 'detail', size: 300 },
{ type: 'datetime', key: 'createdAt' },
],
[
{ key: 'idx_customer_created', type: 'key', attributes: ['customerId', 'createdAt'] },
{ key: 'idx_created', type: 'key', attributes: ['createdAt'] },
]
)
```
- [ ] **Step 2: Failing tests schreiben** (Nachbau mit `schema: { customerViewLog: schemaKeys('customerViewLog') }`)
```js
test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => {
await logViewAction({ customerId: 'c1', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'news_angelegt', detail: 'x'.repeat(400) })
const [doc] = [...aw.docs('customerViewLog').values()]
assert.equal(doc.detail.length, 300)
assert.equal(doc.action, 'news_angelegt')
assert.ok(Date.parse(doc.createdAt))
})
test('logViewAction wirft nicht, wenn Appwrite scheitert', async () => { /* Nachbau-Schema ohne Feld detail -> 400; Aufruf resolved */ })
test('listViewLog: nur dieser Kunde, neueste zuerst, Form {id, at, staffName, action, detail}', async () => { /* 3 Eintraege c1, 1 Eintrag c2 */ })
test('purgeViewLog loescht nur Eintraege aelter als 365 Tage', async () => {
// seed: createdAt now-366d, now-364d, now-1d -> deleted 1, zwei bleiben
})
test('purgeViewLog arbeitet seitenweise (250 alte Eintraege -> deleted 250)', async () => {})
```
- [ ] **Step 3: Fehlschlag prüfen** — Run: `node --test test/customerViewLog.test.js` → FAIL (Modul fehlt)
- [ ] **Step 4: Implementieren** (`createDocument`, `listDocuments`, `deleteDocument`, `Query` aus `services/appwriteAdmin.js`; Liste mit `Query.equal('customerId')`, `Query.orderDesc('createdAt')`, `Query.limit(limit)`; Aufräumen mit `Query.lessThan('createdAt', grenze)`, `Query.limit(100)` in einer Schleife). Timer in `index.js` wie `purge` der Gutschriften: erster Lauf nach 150 s, dann alle 24 h, Log `[kundenansicht] n Protokolleinträge gelöscht`.
- [ ] **Step 5: Tests** — Run: `node --test test/customerViewLog.test.js` → PASS
- [ ] **Step 6: Commit**
```bash
git add scripts/setup-abo-collections.mjs server/config.js server/services/customerViewLog.js server/index.js test/customerViewLog.test.js
git commit -m "Kundenansicht: Protokoll-Collection customerViewLog mit Schreiben, Lesen und Loeschen nach 12 Monaten"
```
### Task 3: Kundenansicht öffnen (Einmal-Link → Sitzung 2 h)
**Files:**
- Create: `server/services/portalAccessStatus.js`
- Create: `server/services/kundenansicht.js`
- Create: `server/routes/team/customers.js` (in `routes/team/index.js` unter `/customers` einhängen)
- Modify: `server/middleware/session.js:14-22` (`setPortalSession(res, data, { maxAgeMs = SESSION_MAX_AGE_MS } = {})`)
- Modify: `server/services/previewSession.js` (`createPreviewSessionToken(payload, { maxAgeSec } = {})`, `previewSessionCookieOptions({ maxAgeSec } = {})`; ohne Option unverändert `config.preview.sessionMaxAge`)
- Modify: `server/services/staffPreviewTokens.js:24` (Kommentar: Zweck `kundenansicht`)
- Modify: `server/routes/auth.js` (neue Route `GET /kundenansicht`)
- Modify: `test/support/fixtures.js` (neuer Helfer `kundenansichtCookie`)
- Test: `test/kundenansicht.test.js`
**Interfaces:**
- Consumes: `requireStaffJwt` / `req.session` (Task 1), `logViewAction` (Task 2).
- Produces: `getPortalAccessStatus(customerId) -> Promise<{ customer: object|null, hasAccess: boolean, lastLoginAt: string|null }>`; `hasAccess` = Kunde existiert && `customer.portalAccessEnabled` && `customerPortalAccess`-Dokument mit `enabled`.
- Produces in `kundenansicht.js`: `KUNDENANSICHT_TTL_MS = 7200000`; `startKundenansicht(res, { staff, customer, now = Date.now() }) -> Promise<session>` (setzt Portal-Cookie mit `maxAgeMs` = TTL und Preview-Cookie für genau diesen Kunden: Payload `{ userId: staff.appwriteUserId, customerId, email: customer.email, name: customer.name, role: 'customer', labels: [], authSource: 'kundenansicht' }`, `maxAgeSec` = 7200); `isKundenansicht(session) -> boolean` (`viewAs && viewId`); `isExpired(session, now = Date.now()) -> boolean` (`now >= viewExpiresAt`); `endKundenansicht(res)` (beide Cookies löschen); `logOnce(session, action) -> Promise<void>` (protokolliert `abgelaufen`/`beendet` je `viewId` höchstens einmal, merkt sich IDs 3 h im Speicher).
- Produces: Sitzungsform `{ role: 'admin', viewAs: true, viewId: <32 hex>, viewExpiresAt: <ms>, isAdmin, appwriteUserId, adminName, adminEmail, customerId, name, email }`.
- Produces: Test-Helfer `kundenansichtCookie({ customerId = 'c1', viewId = 'a'.repeat(32), viewExpiresAt = Date.now() + 3600000 } = {})` (Mitarbeiter `u_staff2`/Andrej).
- [ ] **Step 1: Failing tests schreiben** (Router `/api/team` + `/api/auth`; `aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true, lastLoginAt: '2026-09-30T10:00:00.000Z' })`; c2 ohne Zugangs-Dokument)
```js
test('Link fuer Mitarbeiter: Einmal-Link auf /api/auth/kundenansicht', async () => {
const res = await post('/api/team/customers/c1/kundenansicht', {}, bearer('jwt-staff'))
assert.equal(res.status, 200)
assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/kundenansicht\?token=[0-9a-f]{64}$/)
})
test('unbekannter Kunde 404, Kunde ohne Portal-Zugang 409 no_portal_access', async () => {
assert.equal((await post('/api/team/customers/gibtsnicht/kundenansicht', {}, bearer('jwt-staff'))).status, 404)
const res = await post('/api/team/customers/c2/kundenansicht', {}, bearer('jwt-staff'))
assert.equal(res.status, 409)
assert.equal(res.body.code, 'no_portal_access')
})
test('Einloesen: Sitzung 2 h, Preview-Cookie nur fuer den Kunden, Protokoll geoeffnet, Weiter zum Dashboard', async () => {
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.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], ['c1', 'Andrej', 'u_staff2'])
assert.match(cookieHeader(res, config.cookieName), /Max-Age=7200/)
const preview = await verifyPreviewSessionToken(cookieValue(res, 'preview_session'))
assert.deepEqual([preview.role, preview.customerId], ['customer', 'c1'])
assert.ok(preview.exp - preview.iat <= 7200)
const log = [...aw.docs('customerViewLog').values()].at(-1)
assert.deepEqual([log.customerId, log.action, log.staffName], ['c1', 'geoeffnet', 'Andrej'])
})
test('Token zweimal oder nach 61 s -> 410 mit Hinweis, Preview-Token gilt hier nicht', async () => {
// zweites Einloesen 410, Text enthaelt 'im Ticketsystem erneut öffnen'
// createStaffPreviewToken({...}) ohne purpose -> 410
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansicht.test.js` → FAIL
- [ ] **Step 3: Implementieren**
`POST /api/team/customers/:id/kundenansicht`: `getPortalAccessStatus` → 404 `{ error: 'Kunde nicht gefunden' }` / 409 `{ code: 'no_portal_access', error: 'Kein Portal-Zugang' }`; sonst `createStaffPreviewToken({ purpose: 'kundenansicht', customerId, staff: req.staff })` und `openUrl` mit `config.publicBaseUrl`. `GET /api/auth/kundenansicht`: `consumeStaffPreviewToken(token, 'kundenansicht')`; null → 410 `text/plain; charset=utf-8` „Der Link ist abgelaufen. Bitte die Kundenansicht im Ticketsystem erneut öffnen.“; Kunde inzwischen ohne Zugang → ebenfalls 410 mit „Kein Portal-Zugang“. Sonst `startKundenansicht`, `logViewAction({ action: 'geoeffnet' })`, Redirect `/dashboard.html`. `viewId` aus `randomBytes(16).toString('hex')`.
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansicht.test.js test/staffSwitch.test.js test/previewAdmin.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/portalAccessStatus.js server/services/kundenansicht.js server/routes/team/customers.js server/routes/team/index.js server/middleware/session.js server/services/previewSession.js server/services/staffPreviewTokens.js server/routes/auth.js test/support/fixtures.js test/kundenansicht.test.js
git commit -m "Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll"
```
### Task 4: Kundenansicht-Wächter (Ablauf, Verwechslung, Schreibsperre)
**Files:**
- Create: `server/middleware/kundenansichtGuard.js`
- Modify: `server/index.js` (`app.use(kundenansichtGuard)` direkt nach `sessionMiddleware()`, also vor dem 15-MB-Parser der Gutschriften)
- Modify: `server/routes/news.js` (`rejectViewAs` und seine vier Verwendungen entfernen; der Wächter übernimmt)
- Test: `test/kundenansichtGuard.test.js`
**Interfaces:**
- Consumes: `isKundenansicht`, `isExpired`, `endKundenansicht`, `logOnce` (Task 3); Fixtures `kundenansichtCookie`, `viewAsCookie`.
- Produces: `kundenansichtGuard(req, res, next)`; exportiert zusätzlich `KUNDENANSICHT_ALLOWED` (Array `[method, RegExp]`) für Tests.
Regeln in dieser Reihenfolge (nur wenn `parsePortalSession(req)?.viewAs`; Pfad = `req.originalUrl` ohne Query; durchgelassen werden Pfade unter `/api/team`, `/api/admin`, `/api/portal-admin`, alles außerhalb `/api/` sowie `GET /api/auth/kundenansicht` und `GET /api/auth/me` – beide behandeln Ablauf und Sitzungswechsel selbst; sonst könnte ein abgelaufenes altes Cookie das Öffnen einer neuen Ansicht blockieren):
1. Neue Ansicht (`viewId`) abgelaufen → Cookies löschen, `logOnce(session, 'abgelaufen')`, 401 `{ code: 'view_expired', error: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }`.
2. Neue Ansicht, Header `X-Kundenansicht` vorhanden und ≠ `viewId` → 409 `{ code: 'view_replaced', kunde: session.name, error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' }`.
3. Methode `GET`/`HEAD`/`OPTIONS` → weiter.
4. Neue Ansicht und Header fehlt → 409 wie in 2.
5. Pfad steht in `KUNDENANSICHT_ALLOWED` **und** es ist eine neue Ansicht → weiter. Erlaubt: `POST /api/news`, `POST /api/news/image`, `PATCH /api/news/:id`, `DELETE /api/news/:id`, `POST /api/funktionen/analyse/heatmap-link`, `POST /api/auth/kundenansicht/ende`, `POST /api/auth/logout`, `POST /api/auth/login`. Für alte Ansichten (ohne `viewId`) nur `logout` und `login`.
6. Sonst 403 `{ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' }`.
- [ ] **Step 1: Failing tests schreiben** (App wie `index.js`: `kundenansichtGuard` + alle Kunden-Router mit denselben Mount-Pfaden: `/api/auth`, `/api/projects`, `/api/features`, `/api/news`, `/api` (customerData), `/api/chat`, `/api/abo/takeover-credits`, `/api/abo`, `/api/funktionen`)
```js
const VIEW = 'a'.repeat(32)
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => {
const routes = writeRoutes([['/api/auth', authRoutes], ['/api/news', newsRoutes], /* ... alle oben */])
assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`)
for (const { method, path } of routes) {
if (KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(path))) continue
const res = await call(method, path, kv())
assert.equal(res.status, 403, `${method} ${path}`)
assert.equal(res.body.code, 'view_blocked', `${method} ${path}`)
}
})
// writeRoutes liest router.stack (layer.route.path, layer.route.methods), ersetzt :param durch 'x'
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
assert.equal((await call('POST', '/api/chat/read', kv())).status, 403)
})
test('News anlegen in der Kundenansicht erlaubt', async () => {
const res = await call('POST', '/api/news', { ...kv(), body: note })
assert.equal(res.status, 201)
})
test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => {
const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } })
assert.equal(res.status, 409)
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins'])
})
test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => {})
test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async () => {
const at = Date.now()
// t.mock.method(Date, 'now', () => at); Cookie mit viewExpiresAt: at; zwei Anfragen
// -> beide 401 view_expired, Set-Cookie leert webklar_portal_session und preview_session, 1 Protokolleintrag
})
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {})
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {})
test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => {
// Cookie mit viewExpiresAt in der Vergangenheit: GET /api/auth/me -> 200 (der Waechter greift nicht ein;
// den Inhalt "reason view_expired" prueft Task 6); GET /api/auth/kundenansicht?token=<neuer Token> -> 302 /dashboard.html
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtGuard.test.js` → FAIL
- [ ] **Step 3: Implementieren** wie in den Regeln oben.
- [ ] **Step 4: Gesamte Suite** — Run: `npm test` → PASS (insbesondere `newsRoutes.test.js`: der dortige View-as-Test erwartet weiterhin 403, jetzt vom Wächter; ist der Wächter im Test-App-Aufbau von `newsRoutes.test.js` nicht eingehängt, den Wächter dort ergänzen, nicht die Erwartung ändern).
- [ ] **Step 5: Commit**
```bash
git add server/middleware/kundenansichtGuard.js server/index.js server/routes/news.js test/kundenansichtGuard.test.js test/newsRoutes.test.js
git commit -m "Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News)"
```
### Task 5: News-Änderungen in der Kundenansicht protokollieren
**Files:**
- Modify: `server/routes/news.js` (nach erfolgreichem `createPost`, `storeNewsImage`, `updatePost`, `deletePost`)
- Modify: `server/services/kundenansicht.js` (`logIfKundenansicht(req, action, detail = '') -> Promise<void>`)
- Test: `test/kundenansichtNews.test.js`
**Interfaces:**
- Consumes: `logViewAction` (Task 2), `isKundenansicht` (Task 3).
- Produces: `logIfKundenansicht(req, action, detail)`; tut nichts außerhalb einer neuen Kundenansicht; Mitarbeiter aus `req.session.appwriteUserId`/`adminName`, Kunde aus `req.session.customerId`.
Detail: anlegen/ändern → `post.title || erste 60 Zeichen von post.text`; löschen → Titel/Text des Beitrags vor dem Löschen (`listPosts` liefert ihn nicht einzeln: vorher per `getDocument(config.collections.newsPosts, id)` lesen); Foto → leer.
- [ ] **Step 1: Failing tests** (Aufbau aus `newsRoutes.test.js` übernehmen, Wächter einhängen)
```js
test('anlegen, aendern, Foto, loeschen in der Kundenansicht -> vier Protokolleintraege', async () => {
// POST note (title 'Hallo') -> news_angelegt 'Hallo'; PATCH title 'Neu' -> news_geaendert 'Neu';
// POST /image -> news_bild ''; DELETE -> news_geloescht 'Neu'; staffName jeweils 'Andrej', customerId 'c1'
})
test('Kunde selbst schreibt -> kein Protokolleintrag', async () => {})
test('Protokoll scheitert -> News-Aktion trotzdem 201', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtNews.test.js` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansichtNews.test.js test/newsRoutes.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/routes/news.js server/services/kundenansicht.js test/kundenansichtNews.test.js
git commit -m "Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren"
```
### Task 6: Ende, `/me` und Team-Leserouten (Portal-Status, Protokoll, Preview-Link)
**Files:**
- Modify: `server/routes/auth.js` (`POST /kundenansicht/ende`; `/me`-Zweig für neue Ansichten)
- Modify: `server/routes/team/customers.js` (`GET /:id/portal`, `GET /:id/protokoll`)
- Create: `server/routes/team/preview.js` (in `routes/team/index.js` unter `/preview-link`)
- Modify: `server/routes/admin/previewOpen.js` (Kern als `buildStaffPreviewLink` exportieren; alte Route behält ihre Admin-Prüfung)
- Test: `test/kundenansichtEnde.test.js`, `test/teamRoutes.test.js`
**Interfaces:**
- Consumes: Tasks 1–3.
- Produces: `buildStaffPreviewLink({ userId, email, name, subdomain, previewUrl }) -> Promise<{ status: number, body: object }>` (Logik unverändert aus `previewOpen.js`: Projekt per Subdomain, `isPreviewProjectReady`, Token, `openUrl` auf `/api/auth/staff-preview`).
- Produces (HTTP, von WOMS Task 9–11 genutzt):
- `GET /api/team/customers/:id/portal` → `{ hasAccess: boolean, lastLoginAt: string|null }` (404 unbekannter Kunde)
- `GET /api/team/customers/:id/protokoll` → `{ entries: [{ id, at, staffName, action, detail }] }`
- `POST /api/team/customers/:id/kundenansicht` → `{ openUrl }` (Task 3)
- `POST /api/team/preview-link` mit `{ subdomain, previewUrl }` → `{ openUrl }` / 404 / 409
- `POST /api/auth/kundenansicht/ende` → `{ redirect }`
- `GET /api/auth/me` in neuer Ansicht → zusätzlich `viewId`, `viewExpiresAt`; abgelaufen → `{ authenticated: false, reason: 'view_expired', message: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }`
- [ ] **Step 1: Failing tests**
```js
// kundenansichtEnde.test.js
test('Beenden: Protokoll beendet, beide Cookies geloescht, Rueckweg zur Kundenseite im WOMS', async () => {
const res = await post('/api/auth/kundenansicht/ende', {}, kv())
assert.equal(res.body.redirect, 'https://ticket.webklar.com/customers/c1')
// Set-Cookie leert webklar_portal_session und preview_session; letzter Protokolleintrag action 'beendet'
})
test('Beenden ohne Kundenansicht -> Rueckweg /customers', async () => {})
test('/me in neuer Ansicht: viewId, viewExpiresAt, admin.name, Preview-Cookie nur fuer den Kunden', async () => {
// preview_session neu ausgestellt (ohne vorhandenes Cookie): role 'customer', customerId 'c1', exp <= viewExpiresAt/1000
})
test('/me abgelaufen -> authenticated false, reason view_expired, Protokoll abgelaufen einmal', async () => {})
test('/me alte Ansicht (viewAsCookie) unveraendert: viewAs true, keine viewId', async () => {})
// teamRoutes.test.js
test('portal: hasAccess und lastLoginAt; c2 ohne Zugang hasAccess false; unbekannt 404', async () => {})
test('protokoll: Eintraege des Kunden neueste zuerst', async () => {})
test('preview-link fuer Mitarbeiter ohne Admin-Label -> openUrl auf staff-preview; nicht bereit -> 409', async () => {})
test('alle Team-Routen ohne JWT -> 401', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtEnde.test.js test/teamRoutes.test.js` → FAIL
- [ ] **Step 3: Implementieren** (`/me`: Zweig für `isKundenansicht(session)` vor dem bisherigen Admin-Zweig; Preview-Refresh mit Kunden-Payload und `maxAgeSec = ceil((viewExpiresAt - now)/1000)`)
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add server/routes/auth.js server/routes/team/customers.js server/routes/team/preview.js server/routes/team/index.js server/routes/admin/previewOpen.js test/kundenansichtEnde.test.js test/teamRoutes.test.js
git commit -m "Kundenansicht: Beenden mit Rueckweg ins Ticketsystem, /me mit Ansichts-Kennung, Team-Routen Portal-Status, Protokoll, Preview-Link"
```
### Task 7: Dashboard: Kennung mitsenden, Banner, Ersetzt-/Abgelaufen-Hinweis
**Files:**
- Create: `public/kundenansicht.js` (klassisches Skript, kein Modul)
- Modify: `public/dashboard.html:288` (`<script src="/kundenansicht.js"></script>` vor `chat-composer.js`)
- Modify: `public/app.js:632-645` (`renderViewAsBanner`), `public/app.js:2069-2092` (Init), Aufrufe von `/api/chat/read` (Zeilen ~775, ~812, ~2007: in der Kundenansicht nicht aufrufen)
- Modify: `public/kundenbereich.css` (Overlay `.kb-view-overlay`)
- Test: `test/kundenansichtClient.test.js` (lädt `public/kundenansicht.js` per `node:vm` mit Fake-`window`)
**Interfaces:**
- Consumes: `/api/auth/me` (`viewId`), `POST /api/auth/kundenansicht/ende` (Task 6), Fehlercodes aus Task 4.
- Produces: `window.KbView = { setId(id), id(), showReplaced(kunde), showExpired() }`. Das Skript ersetzt `window.fetch` und `XMLHttpRequest.prototype.open/send` so, dass bei gesetzter ID jede Anfrage an dieselbe Herkunft mit Pfad `/api/…` den Header `X-Kundenansicht` bekommt. Antworten 409 `view_replaced` / 401 `view_expired` (Body per `response.clone().json()`) blenden ein Vollbild-Overlay ein und lassen die Antwort sonst unverändert durch.
Texte (fest):
- Banner: `Kundenansicht: <strong>{Kunde}</strong> · geöffnet von {Mitarbeiter}` + Knopf `Beenden`.
- Ersetzt: „Diese Ansicht wurde durch die Kundenansicht von {kunde} ersetzt.“ + Knopf „Tab schließen“ (`window.close()`).
- Abgelaufen: „Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.“
Verhalten in `app.js`: Nach `/me` bei `me.viewId` → `KbView.setId(me.viewId)` und neues Banner; „Beenden“ → `POST /api/auth/kundenansicht/ende`, dann `location.href = redirect`. Ohne `viewId` bei `me.viewAs` → altes Banner unverändert (Rückweg `/admin.html`). `me.reason === 'view_expired'` → Weiterleitung auf Login mit `error=me.message` wie bei `password_changed`.
- [ ] **Step 1: Failing tests** (`vm.runInNewContext(src, ctx)` mit `ctx.window = ctx`, Fake-`fetch`, das Aufrufe mitschreibt, `location.origin = 'https://project.webklar.com'`, minimalem `document` für das Overlay)
```js
test('ohne ID kein Header', async () => {})
test('mit ID: Header bei /api/... gleicher Herkunft, nicht bei fremder Herkunft oder /img', async () => {
ctx.KbView.setId('a'.repeat(32))
await ctx.fetch('/api/projects')
await ctx.fetch('https://example.org/api/x')
await ctx.fetch('/img/logo.png')
assert.equal(header(calls[0]), 'a'.repeat(32))
assert.equal(header(calls[1]), undefined)
assert.equal(header(calls[2]), undefined)
})
test('409 view_replaced zeigt Overlay mit Kundennamen, Antwort bleibt lesbar', async () => {})
test('401 view_expired zeigt Abgelaufen-Overlay', async () => {})
test('XHR bekommt den Header ebenfalls', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/kundenansichtClient.test.js` → FAIL
- [ ] **Step 3: Implementieren** (`kundenansicht.js`, Banner/Init in `app.js`, CSS: Overlay fixiert über allem, Farben aus den vorhandenen Variablen von `kundenbereich.css`)
- [ ] **Step 4: Tests** — Run: `node --test test/kundenansichtClient.test.js` → PASS
- [ ] **Step 5: Sichtprüfung mit Playwright** nach dem Muster `test/ui/funktionen.ui.mjs` (statischer Server für `public/`, `page.route` für `/api/*`): neues Skript `test/ui/kundenansicht.ui.mjs` mit drei Szenarien (Banner + Beenden-Aufruf; 409 beim Nachladen → Overlay; `me.viewAs` ohne `viewId` → altes Banner). Screenshots nach `/root/reports/admin-umzug-ui/`. Run: `node test/ui/kundenansicht.ui.mjs` → alle Checks „ok“.
- [ ] **Step 6: Commit**
```bash
git add public/kundenansicht.js public/dashboard.html public/app.js public/kundenbereich.css test/kundenansichtClient.test.js test/ui/kundenansicht.ui.mjs
git commit -m "Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht"
```
### Task 8: Datenschutzerklärung Abschnitt 6
**Files:**
- Modify: `public/datenschutz.html:82` (Satz ersetzen), `:47` (`Stand 01.10.2026`)
- Modify: `docs/WEBklar-Datenschutzerklaerung.pdf` (neu erzeugen)
- Test: `test/legalTexts.test.js` (neuer Test)
- [ ] **Step 1: Failing test**
```js
test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren, Protokoll und 12 Monate', () => {
const html = read('public/datenschutz.html')
assert.ok(!html.includes('Eine Nachricht in Ihrem Namen senden können sie dabei nicht.'))
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen.'))
assert.ok(html.includes('Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.'))
assert.ok(html.includes('Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten.'))
assert.ok(html.includes('Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.'))
assert.ok(html.includes('Stand 01.10.2026'))
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test test/legalTexts.test.js` → FAIL
- [ ] **Step 3: Text einsetzen** (Wortlaut exakt aus der Spec, Abschnitt 3 „Datenschutzerklärung“), PDF erzeugen: `scripts/build-legal-pdfs.sh all` und danach `git status --short docs/` prüfen: nur `docs/WEBklar-Datenschutzerklaerung.pdf` darf sich geändert haben; andere geänderte PDFs mit `git checkout -- <datei>` zurücksetzen.
- [ ] **Step 4: Tests** — Run: `node --test test/legalTexts.test.js` → PASS
- [ ] **Step 5: Commit**
```bash
git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf test/legalTexts.test.js
git commit -m "Datenschutz Abschnitt 6: Kundenansicht mit News, Sperren und Protokoll (12 Monate)"
```
---
## WOMS
### Task 9: WOMS-Worktree, `teamApi` und `openInNewTab`
**Files:**
- Create (Worktree): `git -C /home/webklar/apps/ticket.webklar.com/build fetch origin && git -C /home/webklar/apps/ticket.webklar.com/build worktree add ../dev-admin-umzug -b feature/admin-umzug origin/test`, danach `rm -rf node_modules && ln -s ../build/node_modules node_modules` im neuen Worktree
- Create: `src/lib/teamApi.js`
- Create: `src/lib/openInNewTab.js`
- Test: `tests/unit/openInNewTab.test.mjs`
**Interfaces:**
- Consumes: `createFinanceRequest` aus `src/lib/financeRequest.js` (generischer Anfrage-Kern: JWT-Cache, Retry bei 401, deutsche Fehlertexte).
- Produces in `teamApi.js` (Basis `${VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'}/api/team`):
- `getCustomerPortalStatus(customerId) -> Promise<{ hasAccess, lastLoginAt }>`
- `getCustomerViewLog(customerId) -> Promise<{ entries }>`
- `createKundenansichtLink(customerId) -> Promise<string>` (gibt `openUrl` zurück)
- `createPreviewLink({ subdomain, previewUrl }) -> Promise<string>`
- Produces: `openInNewTab(getUrl: () => Promise<string>, { win = globalThis.window } = {}) -> Promise<void>`: öffnet **synchron** `win.open('', '_blank')`, setzt `tab.opener = null`, wartet auf `getUrl()`, setzt `tab.location.href`; bei Fehler `tab.close()` und Fehler weiterwerfen; liefert `win.open` null (Pop-up blockiert), wirft es `new Error('Neuer Tab wurde vom Browser blockiert.')`, ohne `getUrl` aufzurufen.
- [ ] **Step 1: Failing tests**
```js
test('open laeuft vor getUrl, danach wird die Adresse gesetzt', async () => {
const order = []
const tab = { location: {}, close: () => order.push('close') }
const win = { open: () => { order.push('open'); return tab } }
await openInNewTab(async () => { order.push('getUrl'); return 'https://x/y' }, { win })
assert.deepEqual(order, ['open', 'getUrl'])
assert.equal(tab.location.href, 'https://x/y')
assert.equal(tab.opener, null)
})
test('Fehler beim Holen -> Tab wird geschlossen, Fehler weitergereicht', async () => {})
test('Pop-up blockiert -> Fehlermeldung, getUrl nicht aufgerufen', async () => {})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/openInNewTab.test.mjs` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/teamApi.js src/lib/openInNewTab.js tests/unit/openInNewTab.test.mjs
git commit -m "Team-API des Kundenportals und Oeffnen im neuen Tab ohne Pop-up-Sperre"
```
### Task 10: Kundenseite: Tab in der URL, „Übersicht“, Protokoll-Tab
**Files:**
- Modify: `src/lib/customerTabs.js` (Label „Übersicht“, Tab `{ id: 'protokoll', label: 'Protokoll' }` nach `projects`)
- Create: `src/lib/viewLog.js`
- Create: `src/components/customer/CustomerViewLog.jsx`
- Modify: `src/pages/CustomerDetailPage.jsx:15-96` (Tab aus `useSearchParams` statt `useState`; Wechsel per `setSearchParams({ tab }, { replace: true })`; Tab `protokoll` rendert `CustomerViewLog`)
- Test: `tests/unit/customerTabs.test.mjs` (anpassen), `tests/unit/viewLog.test.mjs`
**Interfaces:**
- Consumes: `getCustomerViewLog` (Task 9).
- Produces: `viewLogLabel(action: string) -> string`; Zuordnung: `geoeffnet` → „Kundenansicht geöffnet“, `beendet` → „Kundenansicht beendet“, `abgelaufen` → „Kundenansicht abgelaufen“, `news_angelegt` → „News angelegt“, `news_bild` → „News-Foto hochgeladen“, `news_geaendert` → „News geändert“, `news_geloescht` → „News gelöscht“; unbekannt → der Rohwert.
- Produces: `CustomerViewLog({ customerId })`: Tabelle Zeit (`dd.mm.yyyy hh:mm`, Europe/Berlin) · Mitarbeiter · Aktion · Detail; leer → `EmptyState` „Noch keine Einträge“; Fehler → Fehlerzeile mit Knopf „Erneut versuchen“.
- [ ] **Step 1: Tests anpassen/schreiben**
```js
test('customerTabs: Reihenfolge mit Protokoll, Uebersicht mit Umlaut', () => {
assert.deepEqual(customerTabs(true).map((t) => t.label), ['Übersicht', 'Tickets', 'Rechnungen', 'Projekte', 'Protokoll'])
assert.deepEqual(customerTabs(false).map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll'])
})
test('resolveCustomerTab: protokoll fuer alle', () => {
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
})
test('viewLogLabel: alle sieben Aktionen und Rohwert', () => {
assert.equal(viewLogLabel('news_bild'), 'News-Foto hochgeladen')
assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas')
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/customerTabs.test.mjs tests/unit/viewLog.test.mjs` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests** — Run: `npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/customerTabs.js src/lib/viewLog.js src/components/customer/CustomerViewLog.jsx src/pages/CustomerDetailPage.jsx tests/unit/customerTabs.test.mjs tests/unit/viewLog.test.mjs
git commit -m "Kundenseite: Reiter in der Adresse, Uebersicht mit Umlaut, Protokoll der Kundenansicht"
```
### Task 11: Knopf „Kundenansicht öffnen“, Letzter Login, Preview per Einmal-Link
**Files:**
- Create: `src/components/customer/KundenansichtButton.jsx`
- Create: `src/lib/lastLogin.js`
- Modify: `src/pages/CustomerDetailPage.jsx` (Knopf in `PageHeader.actions` vor „Archivieren“; `PortalAccessCard`: Link „Portal öffnen“ durch `KundenansichtButton` ersetzen, Zeile „Letzter Login“ mit `getCustomerPortalStatus`)
- Modify: `src/components/PreviewLinkButton.jsx` (Props `{ href, subdomain, title, size }`; Klick → `openInNewTab(() => createPreviewLink({ subdomain, previewUrl: href }))`; ohne `subdomain` bleibt der einfache Link)
- Modify: `src/pages/ProjectsPage.jsx:577`, `src/components/AssignedProjectCard.jsx:94` (`subdomain={project.subdomain}` übergeben)
- Test: `tests/unit/lastLogin.test.mjs`
**Interfaces:**
- Consumes: `createKundenansichtLink`, `getCustomerPortalStatus`, `createPreviewLink` (Task 9), `openInNewTab` (Task 9).
- Produces: `KundenansichtButton({ customer })`: aktiv nur bei `customer.portalAccessEnabled && customer.appwriteUserId`, sonst ausgegraut mit `title="Kein Portal-Zugang"`; Beschriftung „Kundenansicht öffnen“ mit Icon `FaArrowUpRightFromSquare`; Fehler → `window.alert(err.message)`.
- Produces: `formatLastLogin(iso: string|null) -> string`: `null`/leer → „noch nie“, sonst `dd.mm.yyyy, hh:mm Uhr` in Europe/Berlin.
- [ ] **Step 1: Failing test**
```js
test('formatLastLogin', () => {
assert.equal(formatLastLogin(null), 'noch nie')
assert.equal(formatLastLogin('2026-09-30T10:05:00.000Z'), '30.09.2026, 12:05 Uhr')
})
```
- [ ] **Step 2: Fehlschlag prüfen** — Run: `node --test tests/unit/lastLogin.test.mjs` → FAIL
- [ ] **Step 3: Implementieren**
- [ ] **Step 4: Tests + Build**
Run: `npm test` → PASS
Run: `systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 npx vite build --outDir /tmp/claude-0/-root/58d3e37c-9aaf-40c4-9aa6-1a699f81c435/scratchpad/woms-out` → „built in“, keine Fehler
- [ ] **Step 5: Sichtprüfung mit Playwright** (Harness nach dem Rezept aus der Memory `woms-ticketsystem`: esbuild-Bündel der Kundenseite mit gestubbten Fremd-Panels, Appwrite-REST und `/api/team/*` per `page.route` nachgebaut, Speicherbremse `MemoryMax=700M`): Checks — Knopf aktiv/ausgegraut, Klick öffnet einen Tab und setzt die `openUrl`, Fehler schließt den Tab; `?tab=protokoll` öffnet direkt den Protokoll-Reiter; Reiterwechsel ändert die Adresse; „Letzter Login“ zeigt den formatierten Wert; Preview-Knopf ruft `/api/team/preview-link`. Harness im Scratchpad, nicht committen.
- [ ] **Step 6: Commit**
```bash
git add src/components/customer/KundenansichtButton.jsx src/lib/lastLogin.js src/pages/CustomerDetailPage.jsx src/components/PreviewLinkButton.jsx src/pages/ProjectsPage.jsx src/components/AssignedProjectCard.jsx tests/unit/lastLogin.test.mjs
git commit -m "Kundenseite: Kundenansicht oeffnen, letzter Login, Preview per Einmal-Link"
```
---
## Abschluss
### Task 12: Gesamtprüfung und Übergabe
- [ ] **Step 1:** Portal: `npm test` → alle grün (vorher bekannte Flakes: `repoMove`; bei Fehlschlag einzeln wiederholen und im Bericht nennen).
- [ ] **Step 2:** Portal unter Node 20 (Produktion): `docker run --rm -v $PWD:$PWD:ro -v /home/webklar/apps/kundenbereich.webklar.com/build/node_modules:/home/webklar/apps/kundenbereich.webklar.com/build/node_modules:ro -w $PWD node:20-alpine node --test test/teamAuth.test.js test/kundenansicht*.test.js test/teamRoutes.test.js test/customerViewLog.test.js` → PASS.
- [ ] **Step 3:** WOMS: `npm test` → grün; Build aus Task 11 Step 4 erfolgreich.
- [ ] **Step 4:** Abschlussreview des ganzen Branches (beide Repos) durch einen frischen Reviewer (Modell sonnet).
- [ ] **Step 5:** Übergabe an den Nutzer mit Livegang-Reihenfolge:
1. Portal pushen: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`
2. Collection anlegen: `docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs` (scheitert es an Rechten: Felder aus Task 2 in der Appwrite-Konsole anlegen)
3. Live-Check (ich): `/api/team/customers/x/portal` ohne JWT → 401; CORS-Preflight von `https://ticket.webklar.com` → 204 mit neuer Header-Liste; `datenschutz.html` enthält den neuen Satz.
4. WOMS pushen: `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`
5. Live-Check (ich): ausgeliefertes Bundle enthält „Kundenansicht öffnen“ und `/api/team/`.
6. Nutzer-Test: Kundenansicht bei einem Kunden öffnen, Banner prüfen, Beenden, Protokoll-Tab ansehen.
@@ -0,0 +1,309 @@
# Admin-Panel ins Ticketsystem – Design
Stand: 2026-10-01 · Status: vom Nutzer im Chat freigegeben (Abschnitte 1–5), Spec zur Durchsicht
Betrifft zwei Repos: **Portal** `knso/Webklar-Kundenbereich` (project.webklar.com, Branch `main`) und
**WOMS** `JUSN/tickte-system` (ticket.webklar.com, Branch `test`).
## Ziel
project.webklar.com ist nur noch die Oberfläche für Kunden. Das Admin-Panel (`public/admin.html`) wird
abgeschafft. Mitarbeiter arbeiten ausschließlich im Ticketsystem (WOMS) und kommen nur noch für zwei Dinge
auf project.webklar.com: eine **Preview** ansehen oder das **Portal eines Kunden so öffnen, wie er es sieht**
(z. B. bei Fernwartung am Telefon), jeweils per Klick aus dem Ticketsystem.
## Entscheidungen (vom Nutzer)
| # | Entscheidung |
|---|---|
| E1 | Admin-Panel fällt komplett weg. „Übersicht“ und „Projekte“ des Panels ersatzlos (Projekte gibt es im WOMS). |
| E2 | Kundenansicht: **Variante A**. Erlaubt ist, was den Kunden nichts kostet; gesperrt bleiben Verträge, Geld und Passwort. Jede Aktion wird **protokolliert**. |
| E3 | **Alle Mitarbeiter gleich**: Chat, Gutschriften, Abo-Angebote, E-Mail-Speicher und Kundenansicht für jeden Mitarbeiter, nicht nur Admins. |
| E4 | Rechnungsanalyse (`kalkulation.html`) **erstmal raus**; Code bleibt in Git, kommt zurück, wenn die Leads wieder laufen. |
| E5 | **Alles zu einem Kunden an einem Ort**: neue Tabs auf der WOMS-Kundenseite; Sidebar-Unterpunkte unter „Kunden“ sind Sammellisten. |
| E6 | Technischer **Weg 1**: Der Portal-Server bleibt das Backend (Stripe, SMTP, Dateien), WOMS bekommt die Oberflächen. |
| E7 | Ausnahmen bei den Sammellisten: **Chat** ist ein Messenger mit zwei Spalten; **E-Mail-Speicher** hakt man direkt in der Liste ab. |
| E8 | In der Kundenansicht sind auch **Erklärungen im Namen des Kunden** gesperrt (Chat als Kunde, Gutschrift beantragen, Rückfrage beantworten). In der Praxis wird damit heute nur **News** frei. |
| E9 | Kundenansicht endet nach **2 Stunden**; Protokoll wird nach **12 Monaten** gelöscht; Datenschutzerklärung bekommt dazu einen Satz. |
| E10 | Mitarbeiter-Login auf project.webklar.com wird gesperrt (Hinweis + Link ins Ticketsystem). |
| E11 | Team-Mails: Texte und Design bleiben, nur Links (und Linktext „Admin-Bereich“ → „Ticketsystem“). |
| E12 | Umsetzung in **4 Etappen**; das Admin-Panel bleibt bis Etappe 4 in Betrieb. |
## Ist-Zustand (kurz)
- Admin-Panel: Tabs Übersicht, Projekte, Kunden, Chat, Gutschriften, Abo-Angebote, E-Mail-Speicher. Es spricht
`/api/portal-admin/*` mit Cookie-Sitzung (`requireAdminSession`, `server/routes/portalAdmin.js:38`). Jede
Mitarbeiter-Anmeldung im Portal (Eintrag in `employees` **oder** Label `admin`) bekommt diese Sitzung.
- WOMS ruft das Portal heute schon cross-origin mit Appwrite-JWT auf (`/api/admin/*`, `requireAppwriteStaff`,
verlangt Label `admin`): Finanzen, Kunden anlegen/löschen, Projekte, Services, Analyse.
- „Als Kunde ansehen“ gibt es (`POST /api/portal-admin/view-as/:id`); jede Schreib-Route sperrt sich dort einzeln.
- WOMS: Sidebar ist eine flache Liste (`src/components/Navbar.jsx:57-69`), Kundenseite
`src/pages/CustomerDetailPage.jsx` mit Tabs aus `src/lib/customerTabs.js` (Tab nur im React-State).
## 1. WOMS: Sidebar und Kundenseite
### Sidebar
```
Kunden
Chat ● n ungelesene Kundennachrichten
Gutschriften ● n offene Anträge
Abo-Angebote
E-Mail-Speicher ● n offene Speicher-Aufgaben
```
- Einträge in `links` bekommen optional `children`; Unterpunkte sind eingerückt und immer sichtbar. Eingeklappt
erscheinen sie als Symbole unter dem Kunden-Symbol. Zahlen nur, wenn > 0.
- Zahlen kommen aus einem Hook, der **alle 60 s** fragt, solange der Browser-Tab sichtbar ist
(`document.visibilityState`). Fehler lassen die letzte Zahl stehen.
- Der Knopf **„Projekt-Admin“** entfällt (Etappe 4).
- Routen: `/customers/chat` (optional `?kunde=<id>`), `/customers/gutschriften`, `/customers/angebote`,
`/customers/email-speicher`. Statische Segmente gehen in react-router v6 vor `/customers/:id`.
### Kundenseite
Tabs: `Übersicht · Tickets · Rechnungen · Projekte · Chat · Abo & Angebote · Gutschriften · Funktionen · Protokoll`
- „Rechnungen“ bleibt **nur für Admins** (die Finanzen-API ist admin-only). Alle neuen Tabs sehen alle Mitarbeiter.
- „Uebersicht“ wird zu „Übersicht“.
- Der Tab steht in der Adresse: `/customers/<id>?tab=chat|abo|gutschriften|funktionen|protokoll|…`; beim
Gutschriften-Tab zusätzlich `&antrag=<id>` (öffnet den Antrag direkt). Unbekannte Tabs → Übersicht (wie heute
`resolveCustomerTab`).
- Kopfzeile: Knopf **„Kundenansicht öffnen“** (neuer Browser-Tab). Ohne Portal-Zugang
(`portalAccessEnabled && appwriteUserId` fehlt) ausgegraut mit Hinweis „Kein Portal-Zugang“.
- Karte „Portal-Zugang“: Link „Portal öffnen“ (führte nur zum Login) wird durch denselben Knopf ersetzt; neu ist
die Zeile **„Letzter Login“**.
## 2. WOMS: die Bereiche
Grundsatz: **Funktionsgleich zum heutigen Admin-Panel**, nur im WOMS-Look. Keine neuen Funktionen. Mails an
Kunden und Stripe-Buchungen bleiben exakt wie heute (gleiche Server-Logik).
### Chat (Messenger `/customers/chat` und Kunden-Tab „Chat“)
- Links: Gesprächsliste (ungelesene oben, letzte Nachricht, Zeit) und Feld „Neue Nachricht an Kunde…“
(Kundenauswahl). Rechts: Gespräch. Im Kunden-Tab steckt dasselbe Gespräch ohne Liste.
- Anhänge wie heute: Bild, Kamerafoto, Datei, Sprachnachricht; Lightbox, Audio-Player, Download.
- Eingabezeile und Sprechblasen: WOMS lädt **`chat-composer.js` und `chat-composer.css` zur Laufzeit von
project.webklar.com** (eine Quelle, kein Auseinanderlaufen) und bettet `ChatComposer.create(...)` /
`ChatComposer.renderMessage(...)` in eine React-Komponente ein. Farben gleicht eine kleine WOMS-CSS an.
Der Composer bekommt eine neue Option für Anfrage-Header (Bearer-JWT beim Hochladen und Entfernen); ohne
die Option verhält er sich wie bisher (Kundenseite unverändert).
- Nach dem Senden zeigt WOMS das Ergebnis des Mail-Hinweises an den Kunden (K17) wie heute im Panel.
- Neue Nachrichten im offenen Gespräch: Abfrage alle 15 s (wie das Panel heute, nur bei sichtbarem Tab);
gelesene Kundennachrichten werden wie heute als gelesen markiert.
### Gutschriften (Sammelliste und Kunden-Tab)
- Filter „Offen“ / „Alle“; im Kunden-Tab nur die Anträge dieses Kunden.
- Detail wie heute: Antrag, Nachweise (öffnen), automatische Prüfung, Rechner (Rechnungsbetrag, Rechnung gilt
für, Bezahlt bis, Übernahme ab), weitere Anträge des Kunden, Verlauf, Stripe-Guthaben.
- Aktionen wie heute: „Genehmigen & gutschreiben“, „Rückfrage senden“, „Ablehnen“, „Jetzt in Stripe
gutschreiben“, „In Stripe öffnen“.
### Abo & Angebote (Sammelliste „Abo-Angebote“ und Kunden-Tab)
- Kunden-Tab oben: **Abo-Status** (Abo, Leistungsbeginn, nächste Abbuchung, gekündigt zum, Speicher), sichtbar
für alle Mitarbeiter. Darunter die Angebote dieses Kunden und „Neues Angebot“ (Kunde vorbelegt).
- Sammelliste: Filter „Offen & aktiv“ / „Alle“, „Neues Angebot“ mit Kundenauswahl, Zurückziehen,
Mail erneut senden. Coupon in Stripe wie heute.
### E-Mail-Speicher (nur Sammelliste)
- Gesamtübersicht, fällige konsoleH-Aufgaben mit Anleitung und Knopf „Erledigt“ direkt in der Zeile,
gescheiterte Änderungen der letzten 30 Tage.
### Funktionen (Kunden-Tab)
- Pro Projekt News an/aus, wie heute im Kunden-Tab des Panels.
### Protokoll (Kunden-Tab)
- Einträge der Kundenansicht dieses Kunden, neueste zuerst: Zeit, Mitarbeiter, Aktion, Detail.
### Ersatzlos weg
Übersicht (Statistik, letzte Logins), Projekt- und Kundentabelle des Panels, Invoice-Ninja-Rechnungsliste
(`GET /customers/:id/invoices`), Kunden-Aktivität (`/customers/:id/activity`, im WOMS-Tab „Tickets“ enthalten),
Kundennummer-Bearbeitung des Panels (Stammdaten bearbeitet WOMS schon selbst).
## 3. Portal: Kundenansicht und Protokoll
### Öffnen
1. WOMS öffnet synchron einen leeren Tab (gegen Pop-up-Sperren), ruft
`POST /api/team/customers/:id/kundenansicht` (Bearer-JWT) und setzt danach die Adresse des Tabs.
2. Der Server prüft: Kunde existiert und hat Portal-Zugang. Er erzeugt einen **Einmal-Link**
(`staffPreviewTokens`, neuer Zweck `kundenansicht`, 60 s, einmal einlösbar) und antwortet mit
`{ openUrl: https://project.webklar.com/api/auth/kundenansicht?token=… }`.
3. `GET /api/auth/kundenansicht` löst das Token ein, setzt die Portal-Sitzung
`{ role:'admin', viewAs:true, viewId, viewExpiresAt, appwriteUserId, adminName, adminEmail, customerId, name, email }`
(`viewId` = zufällige Kennung, `viewExpiresAt` = jetzt + 2 h, Cookie-Laufzeit ebenfalls 2 h) und ein
**Preview-Cookie für genau diesen Kunden** (Rolle Kunde, 2 h), damit Previews aus der Kundenansicht
funktionieren. Protokoll: `geoeffnet`. Weiter auf `/dashboard.html`.
4. Abgelaufenes oder verbrauchtes Token: Hinweisseite „Link abgelaufen, bitte im Ticketsystem erneut öffnen“.
### Banner und Ende
- Banner oben: „Kundenansicht: *Kunde* · geöffnet von *Mitarbeiter* · Beenden“.
- „Beenden“: `POST /api/auth/kundenansicht/ende` → Protokoll `beendet`, Portal- und Preview-Cookie löschen,
Weiterleitung auf `https://ticket.webklar.com/customers/<id>` (Basis aus neuer Variable `TICKET_BASE_URL`).
- Nach 2 h: jede Anfrage antwortet `401 { code:'view_expired' }`, Cookies werden gelöscht, Protokoll
`abgelaufen`; das Dashboard zeigt „Kundenansicht abgelaufen – im Ticketsystem erneut öffnen“.
### Schutz vor Verwechslung (ein Browser = eine Portal-Sitzung)
- `/api/auth/me` liefert in der Kundenansicht `viewId`. Alle API-Aufrufe des Dashboards (`app.js`,
`funktionen.js`, `analyse-view.js`, Composer) senden den Header `X-Kundenansicht: <viewId>`.
- Server-Middleware vor allen Kunden-Routen: In einer Kundenansicht mit abweichender Kennung → `409
{ code:'view_replaced', kunde:<Name> }`. Schreibende Anfragen **ohne** Kennung → ebenfalls 409.
- Das Dashboard rendert bei 409 nichts Neues, sondern zeigt nur: „Diese Ansicht wurde durch die
Kundenansicht von *B* ersetzt. Tab schließen.“
### Was erlaubt ist (fail-closed)
- Neue zentrale Middleware: In einer Kundenansicht ist **jede nicht-lesende Anfrage gesperrt** (403
„In der Kundenansicht nicht möglich“), außer einer ausdrücklichen Erlaubnisliste:
- News: anlegen, Bild hochladen, ändern, löschen (`/api/news` POST/PATCH/DELETE; `rejectViewAs` entfällt dort).
- `POST /api/funktionen/analyse/heatmap-link` (nur ein Ansichts-Link, kostet nichts).
- Ende der Kundenansicht und Abmelden.
- Damit bleiben gesperrt: Abo bestellen/verwalten/wechseln, Speicher, News kaufen, Analyse an/aus (AVV-Zustimmung),
Passwort, Chat senden/Anhänge, Gutschrift beantragen/antworten – und **jede künftige Schreib-Route**, bis sie
bewusst auf die Liste kommt. Die bestehenden Einzelsperren bleiben als zweite Sicherung stehen.
- `POST /api/chat/read` fällt unter die Sperre: Die Kundenansicht markiert Team-Nachrichten **nicht** mehr als
„vom Kunden gelesen“ (Korrektur).
- Lesende Anfragen mit Nebenwirkung, die bleiben dürfen: Abgleich offener Wechsel (`GET /api/abo`),
Freischalten fertiger Funktionen und Rechnungs-Links (`GET /api/funktionen`) – reine Synchronisierung.
- Das Dashboard blendet gesperrte Bedienelemente in der Kundenansicht weiter aus bzw. deaktiviert sie (wie heute).
### Protokoll
- Neue Appwrite-Collection **`customerViewLog`**: `customerId` (string 64, Pflicht), `staffUserId` (string 64),
`staffName` (string 128), `action` (string 32), `detail` (string 300); Index `customerId` + `$createdAt`.
Nur der Server-Schlüssel schreibt/liest (keine Client-Rechte).
- Aktionen: `geoeffnet`, `beendet`, `abgelaufen`, `news_angelegt`, `news_bild`, `news_geaendert`,
`news_geloescht` (Detail: Titel bzw. Anfang des Textes).
- Geschrieben **vom Server** im Anschluss an die erfolgreiche Aktion; schlägt das Schreiben fehl, wird das
geloggt (Server-Log), die Aktion selbst bleibt gültig.
- Löschen: täglicher Aufräumlauf im Portal-Server, Einträge älter als 12 Monate.
- Anlegen: Setup-Skript mit dem Server-Schlüssel; fehlt dem Schlüssel das Recht, legt der Nutzer die Collection
nach Vorgabe in der Appwrite-Konsole an.
### Datenschutzerklärung (Portal, Abschnitt 6)
Der Satz „Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so ansehen, wie Sie es sehen. Eine Nachricht
in Ihrem Namen senden können sie dabei nicht.“ wird ersetzt durch:
> Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie
> News-Beiträge anlegen, ändern oder löschen. Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie
> Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen. Jedes Öffnen und jede Änderung halten wir
> mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten. Rechtsgrundlage dafür ist
> Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.
„Stand“ aktualisieren, PDF-Fassung `docs/WEBklar-Datenschutzerklaerung.pdf` neu erzeugen (wie in d4241d2).
Geht mit Etappe 1 live.
## 4. Portal: Schnittstelle für das Ticketsystem und Abschaltung
### Neuer Eingang `/api/team/*`
- Middleware `requireStaffJwt`: prüft `Authorization: Bearer <Appwrite-JWT>` über Appwrite `/account` (wie
`requireAppwriteStaff`), dann `resolveStaffAccount` (Eintrag in `employees` **oder** Label `admin`); sonst 403.
Sie stellt die Mitarbeiter-Identität in derselben Form bereit wie die bisherige Admin-Sitzung
(`appwriteUserId`, `name`, `email`, `isAdmin`), damit die bestehenden Handler unverändert bleiben.
Die 2FA ist damit abgedeckt: Appwrite beantwortet `/account` ohne abgeschlossenen zweiten Faktor mit 401
(`user_more_factors_required`), die Middleware lässt dann nichts durch.
- Routen (bestehende Logik, neu eingehängt; während Etappe 1–3 parallel zu `/api/portal-admin/*`):
| Neu unter `/api/team` | Herkunft |
|---|---|
| `GET /chats`, `GET /chats/unread-count`, `GET/POST /chats/:id/messages`, `POST /chats/:id/attachments`, `GET/DELETE /chats/:id/attachments/:att`, `POST /chats/:id/read` | `portalAdmin.js` (Chat-Teil wird eigenes Modul) |
| `/takeover-credits/*` (+ Filter `?customerId=`) | `takeoverAdminRouter` |
| `/offers/*` (+ Filter `?customerId=`) | `offersAdminRouter` |
| `/storage-tasks/*` + neu `GET /storage-tasks/open-count` (ohne Stripe-Abgleich, für die Sidebar-Zahl) | `storageTasksAdminRouter` |
| `/funktionen/:customerId[/news]` | `funktionenAdmin` |
| `GET /customers/:id/abo` | Logik von `/api/admin/customers/:id/abo`, jetzt für alle Mitarbeiter |
| `GET /customers/:id/portal` → `{ hasAccess, lastLoginAt }` | neu (`customerPortalAccess`) |
| `POST /customers/:id/kundenansicht` | neu (ersetzt `view-as`) |
| `GET /customers/:id/protokoll` | neu |
| `POST /preview-link` | Logik von `/api/admin/preview-open`, jetzt für alle Mitarbeiter |
- **Dateien ohne Header** (Bilder, Sprachnachrichten, Downloads im Chat; Nachweise bei Gutschriften): Die
Antworten enthalten **signierte Links** (HMAC mit dem Server-Geheimnis, gebunden an Kunde + Datei, 15 min
gültig). Die Datei-Routen akzeptieren Bearer-JWT **oder** gültige Signatur.
- CORS: erlaubte Header zusätzlich `X-File-Name`, `X-Attachment-Kind`, `X-Duration-Ms`, `X-Image-Width`,
`X-Image-Height` (Chat-Upload). Herkunft weiter nur `https://ticket.webklar.com` (+ localhost).
- WOMS-Seite: ein gemeinsamer Helfer `teamRequest` nach dem Muster von `src/lib/financeRequest.js`
(JWT-Cache, einmal neu versuchen bei 401, deutsche Fehlertexte).
### Previews aus dem Ticketsystem
- Die Preview-Knöpfe im WOMS (`PreviewLinkButton`) sind heute einfache Links und funktionieren nur dank der
Mitarbeiter-Anmeldung im Portal. Sie holen sich ab Etappe 1 einen Einmal-Link über `POST /api/team/preview-link`
(leerer Tab synchron, Adresse danach setzen).
### Was in Etappe 4 wegfällt
- `public/admin.html`, `public/admin.js` (und nur dort genutzte Styles), `public/kalkulation.{html,js,css}`.
- `/api/portal-admin/*` komplett, darin auch: overview, projects, customers (Liste, PATCH, activity, invoices),
appointments, `customers/:id/appointment`, `GET customers/:id`, `view-as`, `exit-view-as`.
- `/api/admin/switch-link`, `GET /api/auth/staff-switch`, Zweck `switch` in `staffPreviewTokens`,
`/api/admin/preview-open` (ersetzt durch `/api/team/preview-link`). Die Einlöse-Route
`GET /api/auth/staff-preview` **bleibt**: sie löst auch die Links von `/api/team/preview-link` ein.
- Mitarbeiter-Login: `POST /api/auth/login` erkennt Mitarbeiter wie heute (`resolveStaffAccount`), beendet die
gerade erzeugte Appwrite-Sitzung und antwortet `403 { error:'Mitarbeiter arbeiten im Ticketsystem.', ticketUrl }`;
die Login-Seite zeigt den Hinweis mit Link. `/api/auth/login/mfa`, `/login/mfa/resend`, `staffMfa` und
`startStaffPortalSession` entfallen. Kundenlogin unverändert.
- Alte Mitarbeiter-Sitzungen (Rolle admin ohne Kundenansicht): Dashboard und Login leiten ins Ticketsystem.
- WOMS (vorher live): Knopf „Projekt-Admin“ und `openProjectAdmin()` entfernen.
- Team-Mails:
- Fußzeilen-Link „Admin-Bereich“ in 15 Vorlagen → „Ticketsystem“, `https://ticket.webklar.com/customers`.
- T01 „Neue Chatnachricht“: Knopf „Im Ticketsystem antworten →“ → `/customers/chat?kunde=<customerId>`
(neuer Platzhalter).
- T10/T11: `admin_link` → `/customers/<customerId>?tab=gutschriften&antrag=<id>`.
- Code-Kommentare/Texte, die „Admin-Panel“ nennen, werden angepasst.
## 5. Etappen und Livegang
Jede Etappe ist für sich livefähig; das Admin-Panel läuft bis Etappe 4 weiter.
| Etappe | Inhalt |
|---|---|
| **1 Fundament + Kundenansicht** | `/api/team`-Eingang (Middleware, CORS, `teamRequest`), Kundenansicht (Einmal-Link, Sitzung 2 h, Banner, Verwechslungsschutz, fail-closed-Sperre, News frei, Chat-Lesestatus), Protokoll (Collection, Schreiben, Aufräumen, Tab), Datenschutz-Satz, Previews per Einmal-Link, WOMS: Tab in der Adresse, „Übersicht“, Knopf „Kundenansicht öffnen“, Letzter Login, Abo-Status-Route. |
| **2 Chat** | Chat-Routen unter `/api/team`, signierte Datei-Links, Composer-Option für Header, WOMS-Messenger, Kunden-Tab, Sidebar mit Unterpunkten und Chat-Zahl. |
| **3 Gutschriften, Abo & Angebote, E-Mail-Speicher, Funktionen** | Routen unter `/api/team` (+ Kundenfilter, `open-count`), signierte Nachweis-Links, Sammellisten und Kunden-Tabs, restliche Sidebar-Zahlen. |
| **4 Abschalten** | WOMS: „Projekt-Admin“ entfernen (zuerst live). Portal: Admin-Panel, Rechnungsanalyse, `/api/portal-admin`, Switch/Preview-open-Altlasten, Mitarbeiter-Login sperren, Mail-Links. |
- Reihenfolge je Etappe: **Portal zuerst** (abwärtskompatibel), dann WOMS. Ausnahme Etappe 4: WOMS zuerst.
- Pushen macht der Nutzer (Portal `…:main`, WOMS `…:test`; WOMS deployt jeden gepushten Branch, also nie
Feature-Branches pushen). Danach prüfe ich den Live-Stand lesend.
- Arbeitsorte: Portal-Worktree `dev-admin-umzug` (Branch `feature/admin-umzug`), WOMS-Worktree gleichen Namens.
## 6. Tests
- **Portal:** bestehende Unit-Tests (`node --test`) plus neue:
- `requireStaffJwt`: ohne JWT 401, ungültig 401, Nicht-Mitarbeiter 403, Mitarbeiter ohne Label `admin` erlaubt.
- Kundenansicht: Token einmalig und 60 s; Sitzung endet nach 2 h; **jede** Schreib-Route außerhalb der
Erlaubnisliste liefert 403 (Test läuft über die registrierten Express-Routen, damit neue Routen automatisch
erfasst werden); News-Aktionen erlaubt und protokolliert; `POST /api/chat/read` gesperrt.
- Verwechslung: Tab-Kennung A nach Wechsel zu B → 409, keine Daten von B.
- Signierte Links: falscher Kunde, falsche Datei, abgelaufen → 403.
- Aufräumlauf löscht nur Einträge > 12 Monate.
- Abgeschottete Testumgebung (Appwrite-Nachbau, Stripe-Attrappe, Mail-Falle) für Gutschrift/Angebot über
`/api/team` mit Bearer-JWT.
- **WOMS:** Unit-Tests für reine Logik (`customerTabs` mit neuen Tabs/URL, Sidebar-Kinder, signierte-Link-Helfer,
`teamRequest`); Playwright mit nachgebauten Schnittstellen unter Speicherbremse (`systemd-run … MemoryMax`).
- **Nach jedem Livegang:** lesender Live-Check (Routen antworten 401 ohne JWT, CORS-Preflight 204, ausgelieferte
Bundles enthalten die neuen Teile). Keine echten Kundendaten verändern; Testdaten klar benannt und danach löschen.
## Fehlerbehandlung
- JWT abgelaufen → `teamRequest` holt ein neues und versucht einmal erneut; danach Fehlermeldung.
- Portal nicht erreichbar → jede Ansicht zeigt eine Fehlerzeile mit „Erneut versuchen“; Sidebar-Zahlen behalten
den letzten Stand.
- Kunde ohne Portal-Zugang → Knopf ausgegraut; Server antwortet zusätzlich 409 „Kein Portal-Zugang“.
## Nicht Teil dieser Spec
- Rechnungsanalyse/Kalkulation im WOMS (E4, später eigenes Vorhaben).
- Neue Funktionen in Chat, Gutschriften, Angeboten oder E-Mail-Speicher.
- Änderungen an Kunden-Logins, Kunden-Mails (K01–K19) oder an der WOMS-2FA.
+26 -5
View File
@@ -634,6 +634,19 @@ function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
if (!banner || !me.viewAs) return
banner.classList.remove('hidden')
// Kundenansicht aus dem Ticketsystem (mit Kennung): Beenden fuehrt zurueck auf die Kundenseite im Ticketsystem
if (me.viewId) {
banner.innerHTML = `
<span>Kundenansicht: <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong> · geöffnet von ${escapeHtml(me.admin?.name || 'Mitarbeiter')}</span>
<button type="button" id="exit-viewas-btn">Beenden</button>
`
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
const { redirect } = await api('/api/auth/kundenansicht/ende', { method: 'POST' }).catch(() => ({}))
window.location.href = redirect || 'https://ticket.webklar.com/customers'
})
return
}
// Alte Ansicht "Als Kunde ansehen" aus dem Admin-Panel (bis das Panel abgeschaltet ist)
banner.innerHTML = `
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
<button type="button" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
@@ -765,6 +778,12 @@ function updateChatBadge(unread) {
}
}
// Nachrichten als gelesen markieren; in der Kundenansicht nicht (der Kunde hat sie ja nicht gelesen)
function markChatRead() {
if (chatState.viewAs) return Promise.resolve()
return api('/api/chat/read', { method: 'POST' }).catch(() => {})
}
async function pollChatMessages() {
if (document.hidden) return
try {
@@ -772,7 +791,7 @@ async function pollChatMessages() {
const data = await api(`/api/chat/messages${q}`)
appendChatMessages(data.messages || [])
if (isChatTabActive() && (data.unread || 0) > 0) {
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
await markChatRead()
updateChatBadge(0)
}
} catch {
@@ -809,7 +828,7 @@ async function initChat() {
})
await pollChatMessages()
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
await markChatRead()
updateChatBadge(0)
chatState.pollTimer = setInterval(() => {
if (isChatTabActive()) pollChatMessages()
@@ -2004,7 +2023,7 @@ function showView(name, { updateHash = true } = {}) {
if (box) box.innerHTML = `<p class="muted">${escapeHtml(err.message || 'Konnte nicht geladen werden.')}</p>`
})
if (name === 'chat') {
api('/api/chat/read', { method: 'POST' }).catch(() => {})
markChatRead()
updateChatBadge(0)
}
if (updateHash) {
@@ -2070,8 +2089,8 @@ async function initDashboardPage() {
if (!me.authenticated) {
const target = new URL(loginUrl, window.location.origin)
// Passwort auf einem anderen Gerät geändert: Hinweis auf der Anmeldeseite
if (me.reason === 'password_changed' && me.message) target.searchParams.set('error', me.message)
// Passwort auf einem anderen Gerät geändert oder Kundenansicht abgelaufen: Hinweis auf der Anmeldeseite
if ((me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) target.searchParams.set('error', me.message)
window.location.href = target.pathname + target.search
return
}
@@ -2085,6 +2104,8 @@ async function initDashboardPage() {
}
const customer = me.customer
// Kundenansicht: ab jetzt traegt jede /api-Anfrage die Kennung dieser Ansicht (public/kundenansicht.js)
if (me.viewId) window.KbView?.setId(me.viewId)
chatState.viewAs = Boolean(me.viewAs)
takeoverState.customer = customer
renderViewAsBanner(me)
+2
View File
@@ -285,6 +285,8 @@
</main>
</div>
<!-- Kundenansicht: Kennung an jeder /api-Anfrage, Hinweis bei ersetzter/abgelaufener Ansicht (vor allen anderen Skripten) -->
<script src="/kundenansicht.js"></script>
<script src="/chat-composer.js"></script>
<script src="/app.js"></script>
<script>
+2 -2
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 30.09.2026</p>
<p class="meta">Stand 01.10.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
@@ -79,7 +79,7 @@
<p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p>
<h2>6. Kundenportal</h2>
<p>Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen, Ihre Abos und Bestellungen (Abschnitt 7) sowie gegebenenfalls Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Schreiben wir Ihnen im Chat, erhalten Sie eine E-Mail-Benachrichtigung mit unserer Nachricht, höchstens eine alle 15 Minuten. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so ansehen, wie Sie es sehen. Eine Nachricht in Ihrem Namen senden können sie dabei nicht.</p>
<p>Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen, Ihre Abos und Bestellungen (Abschnitt 7) sowie gegebenenfalls Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Schreiben wir Ihnen im Chat, erhalten Sie eine E-Mail-Benachrichtigung mit unserer Nachricht, höchstens eine alle 15 Minuten. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen. Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen. Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten. Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.</p>
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
+109
View File
@@ -0,0 +1,109 @@
/* Kundenansicht (Mitarbeiter oeffnet das Portal eines Kunden aus dem Ticketsystem).
Ein Browser haelt nur eine Portal-Sitzung: oeffnet jemand Kunde B, waehrend Kunde A noch in einem Tab offen ist,
darf der Tab von A keine Daten von B laden. Deshalb traegt jede eigene /api-Anfrage die Kennung der Ansicht
(X-Kundenansicht); der Server antwortet bei fremder Kennung mit 409 view_replaced, nach 2 Stunden mit
401 view_expired. Dann zeigt dieses Skript nur noch einen Hinweis. Klassisches Skript, vor app.js laden. */
(function () {
'use strict'
const HEADER = 'X-Kundenansicht'
let viewId = ''
function isOwnApi(url) {
try {
const u = new URL(String(url), window.location.href)
return u.origin === window.location.origin && u.pathname.startsWith('/api/')
} catch {
return false
}
}
function overlay(text) {
const existing = document.getElementById('kb-view-overlay-text')
if (existing) {
existing.textContent = text
return
}
const box = document.createElement('div')
box.id = 'kb-view-overlay'
box.className = 'kb-view-overlay'
box.setAttribute('role', 'alertdialog')
box.setAttribute('aria-modal', 'true')
const card = document.createElement('div')
card.className = 'kb-view-overlay-card'
const p = document.createElement('p')
p.id = 'kb-view-overlay-text'
p.textContent = text
const button = document.createElement('button')
button.type = 'button'
button.textContent = 'Tab schließen'
button.addEventListener('click', () => window.close())
card.appendChild(p)
card.appendChild(button)
box.appendChild(card)
document.body.appendChild(box)
}
function showReplaced(kunde) {
overlay(`Diese Ansicht wurde durch die Kundenansicht von ${kunde || 'einem anderen Kunden'} ersetzt.`)
}
function showExpired() {
overlay('Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.')
}
function inspect(status, body) {
if (status === 409 && body && body.code === 'view_replaced') showReplaced(body.kunde)
else if (status === 401 && body && body.code === 'view_expired') showExpired()
}
const originalFetch = window.fetch.bind(window)
window.fetch = async function (input, init) {
const url = typeof input === 'string' ? input : (input && input.url) || String(input)
const own = isOwnApi(url)
let options = init || {}
if (viewId && own) {
const headers = new Headers(options.headers || (typeof input !== 'string' && input && input.headers) || {})
headers.set(HEADER, viewId)
options = { ...options, headers }
}
const response = await originalFetch(input, options)
if (own && (response.status === 409 || response.status === 401)) {
response.clone().json().then((body) => inspect(response.status, body)).catch(() => {})
}
return response
}
if (window.XMLHttpRequest) {
const proto = window.XMLHttpRequest.prototype
const open = proto.open
const send = proto.send
proto.open = function (method, url, ...rest) {
this.kbOwnApi = isOwnApi(url)
return open.call(this, method, url, ...rest)
}
proto.send = function (...args) {
if (this.kbOwnApi) {
if (viewId) this.setRequestHeader(HEADER, viewId)
this.addEventListener('load', () => {
if (this.status !== 409 && this.status !== 401) return
try {
inspect(this.status, JSON.parse(this.responseText))
} catch {
/* keine JSON-Antwort */
}
})
}
return send.apply(this, args)
}
}
window.KbView = {
setId(id) {
viewId = String(id || '')
},
id: () => viewId,
showReplaced,
showExpired,
}
})()
+12
View File
@@ -174,6 +174,18 @@
}
.viewas-banner button { background: transparent; color: #fff; border: 1px solid var(--kb-border-strong); border-radius: 999px; padding: 7px 14px; font: inherit; font-size: 13px; cursor: pointer; }
/* Kundenansicht ersetzt oder abgelaufen (public/kundenansicht.js): deckt die ganze Seite ab, damit nichts Veraltetes bedienbar bleibt */
.kb-view-overlay {
position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
background: rgba(0, 0, 0, 0.78); backdrop-filter: blur(4px);
}
.kb-view-overlay-card {
max-width: 420px; padding: 24px; border-radius: var(--kb-radius, 14px); text-align: center;
background: #121216; border: 1px solid rgba(251, 191, 36, 0.45); color: #fff;
}
.kb-view-overlay-card p { margin: 0 0 18px; line-height: 1.5; }
.kb-view-overlay-card button { background: var(--kb-purple, #9d3dfe); color: #fff; border: none; border-radius: 999px; padding: 9px 18px; font: inherit; cursor: pointer; }
/* Buttons */
.kb-btn-primary {
display: inline-block; padding: 12px 22px; border-radius: 999px; border: none; background: var(--kb-purple);
+19 -1
View File
@@ -2,7 +2,7 @@
/**
* Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits
* (Uebernahmegutschriften), aboOffers (individuelle Angebote), portalFeatures (Portal-Funktionen je
* Projekt) und newsPosts (News-Pinnwand) in woms-database an und
* Projekt), newsPosts (News-Pinnwand) und customerViewLog (Protokoll der Kundenansicht) in woms-database an und
* ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs
@@ -333,4 +333,22 @@ await ensure('worksheets.countsToPlan', () =>
)
await waitForAttributes('worksheets', ['countsToPlan'])
// Kundenansicht: wer wann das Portal eines Kunden geoeffnet oder darin etwas geaendert hat (12 Monate)
await ensureCollection(
'customerViewLog',
'Kundenansicht-Protokoll',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'staffUserId', size: 64 },
{ type: 'string', key: 'staffName', size: 128 },
{ type: 'string', key: 'action', size: 32 },
{ type: 'string', key: 'detail', size: 300 },
{ type: 'datetime', key: 'createdAt' },
],
[
{ key: 'idx_customer_created', type: 'key', attributes: ['customerId', 'createdAt'] },
{ key: 'idx_created', type: 'key', attributes: ['createdAt'] },
]
)
console.log('Fertig.')
+1
View File
@@ -154,6 +154,7 @@ export const config = {
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog',
// Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten)
bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections',
bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions',
+17 -12
View File
@@ -20,6 +20,7 @@ import funktionenRoutes from './routes/funktionen.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
import { purgeViewLog } from './services/customerViewLog.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
@@ -39,6 +40,9 @@ import adminAnalyseRoutes from './routes/admin/analyse.js'
import { analyseLegalRouter } from './services/analyseLegal.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import teamRoutes from './routes/team/index.js'
import { corsMiddleware } from './middleware/cors.js'
import { kundenansichtGuard } from './middleware/kundenansichtGuard.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public')
@@ -48,6 +52,9 @@ assertServerConfig()
const app = express()
app.set('trust proxy', 1)
app.use(sessionMiddleware())
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen
// Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften
app.use(kundenansichtGuard)
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
@@ -55,18 +62,7 @@ app.use('/api/abo/takeover-credits', requireSession, express.json({ limit: '15mb
// rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
app.use((req, res, next) => {
const origin = req.get('Origin')
if (origin && config.cors.allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
}
return next()
})
app.use(corsMiddleware(config.cors.allowedOrigins))
app.use('/api/auth', authRoutes)
app.use('/api/projects', projectsRoutes)
@@ -79,6 +75,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
@@ -153,6 +150,14 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setTimeout(purge, 60 * 1000).unref()
setInterval(purge, 24 * 60 * 60 * 1000).unref()
// Protokoll der Kundenansicht: Eintraege aelter als 12 Monate loeschen (taeglich)
const purgeViewLogJob = () =>
purgeViewLog()
.then((r) => r.deleted && console.log(`[kundenansicht] ${r.deleted} Protokolleinträge gelöscht`))
.catch((err) => console.warn('[kundenansicht] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purgeViewLogJob, 150 * 1000).unref()
setInterval(purgeViewLogJob, 24 * 60 * 60 * 1000).unref()
// Chat-Anhaenge, die nie abgeschickt wurden, nach config.chat.pendingTtlHours loeschen (stuendlich)
const purgeChat = () =>
purgePendingChatUploads()
+17
View File
@@ -0,0 +1,17 @@
// CORS fuer das Ticketsystem (Bearer-JWT, keine Cookies). Die X-*-Header braucht der Chat-Upload.
const ALLOW_HEADERS = 'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height'
export function corsMiddleware(allowedOrigins) {
return (req, res, next) => {
const origin = req.get('Origin')
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
}
return next()
}
}
+60
View File
@@ -0,0 +1,60 @@
import { parsePortalSession } from './session.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):
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
* Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden.
*/
export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/news\/?$/],
['POST', /^\/api\/news\/image\/?$/],
['PATCH', /^\/api\/news\/[^/]+\/?$/],
['DELETE', /^\/api\/news\/[^/]+\/?$/],
['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
const LEGACY_ALLOWED = [
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
// Eigene Pruefung (Ticketsystem-JWT, Admin-Sitzung) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
const replaced = (res, session) =>
res.status(409).json({ code: 'view_replaced', kunde: session.name || '', error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' })
export async function kundenansichtGuard(req, res, next) {
// Express routet ohne Ruecksicht auf Gross-/Kleinschreibung (/API/news trifft /api/news): hier genauso pruefen
const pathname = String(req.path || '').toLowerCase()
if (!pathname.startsWith('/api/')) return next()
if (PASS_PREFIXES.some((p) => pathname.startsWith(p)) || PASS_EXACT.includes(pathname.replace(/\/$/, ''))) return next()
const session = parsePortalSession(req)
if (!session) return next()
const current = isKundenansicht(session)
if (current && isExpired(session)) {
endKundenansicht(res)
await logOnce(session, 'abgelaufen')
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
}
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht, alte
// Admin-Ansicht, Kunden-Login): keine fremden Daten in diesen Tab
const header = req.get('X-Kundenansicht')
if (header && header !== session.viewId) return replaced(res, session)
if (!session.viewAs) return next()
if (READ_METHODS.includes(req.method)) return next()
if (current && !header) return replaced(res, session)
const list = current ? KUNDENANSICHT_ALLOWED : LEGACY_ALLOWED
if (list.some(([method, re]) => method === req.method && re.test(pathname))) return next()
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
}
+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,
})
}
+59
View File
@@ -0,0 +1,59 @@
import { config } from '../config.js'
import { getEmployeeByEmail, getEmployeeByUserId } from '../services/appwriteAdmin.js'
/**
* Appwrite-Konto zum Bearer-JWT aus dem Ticketsystem (GET /account). null ohne Header oder wenn Appwrite
* ablehnt - auch bei einer Sitzung ohne zweiten Faktor (401 user_more_factors_required).
*/
export async function appwriteAccountFromJwt(req) {
const match = (req.get('Authorization') || '').match(/^Bearer\s+(.+)$/i)
if (!match) return null
const response = await fetch(`${config.appwrite.endpoint}/account`, {
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': config.appwrite.projectId,
'X-Appwrite-JWT': match[1],
},
})
if (!response.ok) return null
return response.json()
}
/**
* Mitarbeiter fuer den Team-Eingang: employees-Eintrag zur Konto-ID, Label "admin" oder - nur mit bestaetigter
* Konto-Mail - ein employees-Eintrag zur Mail. Kunden haben Konten im selben Appwrite-Projekt und koennen ihre
* Mail ohne Bestaetigung aendern; eine unbestaetigte Mail zaehlt deshalb nie.
*/
export async function resolveTeamStaff(account) {
if (!account?.$id) return null
const isAdmin = Array.isArray(account.labels) && account.labels.includes('admin')
let employee = await getEmployeeByUserId(account.$id).catch(() => null)
if (!employee && !isAdmin && account.emailVerification === true && account.email) {
employee = await getEmployeeByEmail(account.email).catch(() => null)
}
if (!employee && !isAdmin) return null
return {
isAdmin,
name: employee?.displayName || account.name || 'Mitarbeiter',
email: account.email || employee?.email || '',
}
}
/**
* Team-Eingang (/api/team): jeder Mitarbeiter mit gueltiger Ticketsystem-Anmeldung. req.session hat dieselbe Form
* wie die Admin-Sitzung des Portals, damit die bestehenden Handler unveraendert laufen.
*/
export async function requireStaffJwt(req, res, next) {
try {
const account = await appwriteAccountFromJwt(req)
if (!account?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' })
const staff = await resolveTeamStaff(account)
if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' })
const session = { role: 'admin', isAdmin: staff.isAdmin, appwriteUserId: account.$id, name: staff.name, email: staff.email }
req.session = session
req.staff = session
return next()
} catch (err) {
return res.status(500).json({ error: err.message || 'Anmeldung konnte nicht geprüft werden' })
}
}
+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
+1 -14
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { appwriteAccountFromJwt } from '../../middleware/staffJwt.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
import { resolveStaffAccount } from '../../services/staffAccount.js'
@@ -10,20 +11,6 @@ const router = Router()
* ueber /account geprueft (eine Sitzung ohne zweiten Faktor bekommt dort 401), aber ohne Label-Pflicht:
* jeder Mitarbeiter darf rueber, wie beim normalen Portal-Login.
*/
async function appwriteAccountFromJwt(req) {
const match = (req.get('Authorization') || '').match(/^Bearer\s+(.+)$/i)
if (!match) return null
const response = await fetch(`${config.appwrite.endpoint}/account`, {
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': config.appwrite.projectId,
'X-Appwrite-JWT': match[1],
},
})
if (!response.ok) return null
return response.json()
}
router.post('/', async (req, res) => {
try {
const user = await appwriteAccountFromJwt(req)
+74
View File
@@ -14,6 +14,7 @@ import { loginWithAppwrite, getLoginCooldownRemainingSec, sessionNeedsSecondFact
import {
SESSION_REVOKED_MESSAGE,
clearPortalSession,
parsePortalSession,
requireSession,
setPortalSession,
} from '../middleware/session.js'
@@ -28,6 +29,17 @@ import {
} from '../services/previewSession.js'
import { isPreviewProjectReady } from '../services/previewReadiness.js'
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
import { getPortalAccessStatus } from '../services/portalAccessStatus.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'
@@ -262,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) {
@@ -343,6 +364,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.
@@ -368,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
+15 -11
View File
@@ -4,6 +4,9 @@ import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { NEWS_IMAGE_MAX_BYTES, newsImagePath, newsImageRateLimited, storeNewsImage } from '../services/newsImages.js'
import { createPost, deletePost, listPosts, updatePost } from '../services/newsPosts.js'
import { uploadSlot } from './chat.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { logIfKundenansicht } from '../services/kundenansicht.js'
const router = Router()
@@ -34,13 +37,6 @@ function writeRateLimited(customerId) {
return list.length > MAX_WRITES_PER_HOUR
}
function rejectViewAs(req, res, next) {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
}
return next()
}
function limitWrites(req, res, next) {
if (writeRateLimited(getSessionCustomerId(req))) {
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
@@ -48,6 +44,9 @@ function limitWrites(req, res, next) {
return next()
}
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
@@ -82,9 +81,10 @@ router.get('/', requireSession, withProject(), async (req, res) => {
}
})
router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
router.post('/', requireSession, withProject(), limitWrites, async (req, res) => {
try {
const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body })
await logIfKundenansicht(req, 'news_angelegt', postLabel(post))
res.status(201).json(post)
} catch (err) {
fail(res, err)
@@ -95,7 +95,6 @@ router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async
router.post(
'/image',
requireSession,
rejectViewAs,
withProject(),
(req, res, next) => {
if (newsImageRateLimited(getSessionCustomerId(req))) {
@@ -108,6 +107,7 @@ router.post(
async (req, res) => {
try {
const image = await storeNewsImage({ customerId: getSessionCustomerId(req), projectId: req.projectId, buffer: req.body })
await logIfKundenansicht(req, 'news_bild')
res.status(201).json(image)
} catch (err) {
fail(res, err)
@@ -131,18 +131,22 @@ router.get('/image/:projectId/:file', requireSession, async (req, res) => {
}
})
router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
router.patch('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
try {
const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body })
await logIfKundenansicht(req, 'news_geaendert', postLabel(post))
res.json(post)
} catch (err) {
fail(res, err)
}
})
router.delete('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
router.delete('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
try {
// Titel fuers Protokoll vor dem Loeschen lesen (deletePost prueft den Besitz danach selbst)
const before = req.session.viewId ? await getDocument(config.collections.newsPosts, req.params.id).catch(() => null) : null
await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id })
await logIfKundenansicht(req, 'news_geloescht', postLabel(before))
res.status(204).end()
} catch (err) {
fail(res, err)
+43
View File
@@ -0,0 +1,43 @@
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 {
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
+12
View File
@@ -0,0 +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
@@ -76,6 +76,7 @@ export const Query = {
}),
limit: (n) => ({ method: 'limit', values: [n] }),
greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }),
lessThan: (attribute, value) => ({ method: 'lessThan', attribute, values: [value] }),
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
}
+54
View File
@@ -0,0 +1,54 @@
import { config } from '../config.js'
import { createDocument, deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
/**
* Protokoll der Kundenansicht (Datenschutzerklaerung Abschnitt 6): wer wann das Portal eines Kunden geoeffnet
* oder darin etwas geaendert hat. Schreiben nur ueber den Server-Schluessel; Eintraege leben 12 Monate.
*/
export const VIEW_ACTIONS = ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht']
const DETAIL_MAX = 300
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000
const PAGE = 100
const COLLECTION = () => config.collections.customerViewLog
/** Eintrag schreiben; wirft nie (die protokollierte Aktion selbst bleibt gueltig). */
export async function logViewAction({ customerId, staffUserId, staffName, action, detail = '' }) {
try {
await createDocument(COLLECTION(), {
customerId: String(customerId || ''),
staffUserId: String(staffUserId || ''),
staffName: String(staffName || ''),
action: String(action || ''),
detail: String(detail || '').slice(0, DETAIL_MAX),
createdAt: new Date().toISOString(),
})
} catch (err) {
console.warn(`[kundenansicht] Protokoll (${action}, Kunde ${customerId}) nicht geschrieben:`, err.message)
}
}
/** Eintraege eines Kunden, neueste zuerst. */
export async function listViewLog(customerId, { limit = 200 } = {}) {
const docs = await listDocuments(COLLECTION(), [
Query.equal('customerId', customerId),
Query.orderDesc('createdAt'),
Query.limit(limit),
])
return docs.map((d) => ({ id: d.$id, at: d.createdAt, staffName: d.staffName || '', action: d.action || '', detail: d.detail || '' }))
}
/** Eintraege aelter als 12 Monate loeschen (taeglicher Lauf), seitenweise bis keine mehr uebrig sind. */
export async function purgeViewLog({ now = new Date() } = {}) {
const cutoff = new Date(now.getTime() - RETENTION_MS).toISOString()
let deleted = 0
for (;;) {
const docs = await listDocuments(COLLECTION(), [Query.lessThan('createdAt', cutoff), Query.limit(PAGE)])
for (const doc of docs) {
await deleteDocument(COLLECTION(), doc.$id)
deleted += 1
}
if (docs.length < PAGE) return { deleted }
}
}
+97
View File
@@ -0,0 +1,97 @@
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
const COOKIE_GRACE_MS = 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 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 || '',
}
// Das Cookie lebt eine Stunde laenger als die Ansicht: so meldet der Server nach 2 Stunden "abgelaufen" (Hinweis im
// Tab, Protokolleintrag), statt dass der Browser das Cookie still verwirft
setPortalSession(res, session, { maxAgeMs: KUNDENANSICHT_TTL_MS + COOKIE_GRACE_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)
}
/** Aenderung in der Kundenansicht protokollieren; ausserhalb einer Kundenansicht (Kunde selbst) passiert nichts. */
export async function logIfKundenansicht(req, action, detail = '') {
const session = req.session
if (!isKundenansicht(session)) return
await logViewAction({
customerId: session.customerId,
staffUserId: session.appwriteUserId,
staffName: session.adminName,
action,
detail,
})
}
// "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()
+73
View File
@@ -0,0 +1,73 @@
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'
// Protokoll der Kundenansicht (Collection customerViewLog): schreiben, lesen, nach 12 Monaten loeschen
const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { VIEW_ACTIONS, logViewAction, listViewLog, purgeViewLog } = await import('../server/services/customerViewLog.js')
after(() => Promise.all([aw.close(), st.close()]))
const DAY_MS = 24 * 60 * 60 * 1000
const entries = () => [...aw.docs('customerViewLog').values()]
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt })
test('VIEW_ACTIONS: genau die sieben Aktionen', () => {
assert.deepEqual(VIEW_ACTIONS, ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht'])
})
test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => {
const before = entries().length
await logViewAction({ customerId: 'c9', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'news_angelegt', detail: 'x'.repeat(400) })
const all = entries()
assert.equal(all.length, before + 1)
const doc = all.find((d) => d.customerId === 'c9')
assert.equal(doc.detail.length, 300)
assert.equal(doc.action, 'news_angelegt')
assert.equal(doc.staffName, 'Andrej')
assert.equal(doc.staffUserId, 'u_staff2')
assert.ok(Math.abs(Date.parse(doc.createdAt) - Date.now()) < 5000)
})
test('logViewAction wirft nicht, wenn Appwrite scheitert', async (t) => {
const warn = t.mock.method(console, 'warn', () => {})
aw.failNext({ collection: 'customerViewLog', method: 'POST', status: 500, message: 'kaputt' })
await assert.doesNotReject(() => logViewAction({ customerId: 'c8', staffUserId: 'u', staffName: 'X', action: 'geoeffnet' }))
assert.equal(entries().filter((d) => d.customerId === 'c8').length, 0)
assert.equal(warn.mock.callCount(), 1)
})
test('listViewLog: nur dieser Kunde, neueste zuerst, Form {id, at, staffName, action, detail}', async () => {
seedLog('l1', 'c1', '2026-09-01T10:00:00.000Z')
seedLog('l2', 'c1', '2026-09-03T10:00:00.000Z', 'news_angelegt')
seedLog('l3', 'c1', '2026-09-02T10:00:00.000Z', 'beendet')
seedLog('l4', 'c2', '2026-09-04T10:00:00.000Z')
const list = await listViewLog('c1')
assert.deepEqual(list.map((e) => e.id), ['l2', 'l3', 'l1'])
assert.deepEqual(list[0], { id: 'l2', at: '2026-09-03T10:00:00.000Z', staffName: 'Andrej', action: 'news_angelegt', detail: '' })
})
test('purgeViewLog loescht nur Eintraege aelter als 365 Tage', async () => {
const now = new Date('2026-10-01T12:00:00.000Z')
seedLog('p1', 'c3', new Date(now - 366 * DAY_MS).toISOString())
seedLog('p2', 'c3', new Date(now - 364 * DAY_MS).toISOString())
seedLog('p3', 'c3', new Date(now - 1 * DAY_MS).toISOString())
const { deleted } = await purgeViewLog({ now })
assert.equal(deleted, 1)
assert.deepEqual(entries().filter((d) => d.customerId === 'c3').map((d) => d.$id).sort(), ['p2', 'p3'])
})
test('purgeViewLog arbeitet seitenweise (250 alte Eintraege -> deleted 250)', async () => {
const now = new Date('2026-10-01T12:00:00.000Z')
const old = new Date(now - 400 * DAY_MS).toISOString()
for (let i = 0; i < 250; i += 1) seedLog(`old${i}`, 'c4', old)
seedLog('keep', 'c4', now.toISOString())
const { deleted } = await purgeViewLog({ now })
assert.equal(deleted, 250)
assert.deepEqual(entries().filter((d) => d.customerId === 'c4').map((d) => d.$id), ['keep'])
})
+104
View File
@@ -0,0 +1,104 @@
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'])
// Cookie lebt laenger als die Ansicht (2 h + 1 h), damit der Server 'abgelaufen' melden und protokollieren kann
assert.match(setCookie(res, config.cookieName), /Max-Age=10800/)
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)
})
+173
View File
@@ -0,0 +1,173 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// public/kundenansicht.js im Browser-Nachbau: Kennung X-Kundenansicht an jede eigene /api-Anfrage,
// Hinweis-Overlay bei ersetzter (409 view_replaced) oder abgelaufener (401 view_expired) Ansicht
const SRC = readFileSync(new URL('../public/kundenansicht.js', import.meta.url), 'utf8')
const ID = 'a'.repeat(32)
function fakeElement(tag) {
return {
tag,
id: '',
className: '',
textContent: '',
type: '',
children: [],
attrs: {},
listeners: {},
appendChild(child) {
this.children.push(child)
return child
},
setAttribute(name, value) {
this.attrs[name] = value
},
addEventListener(name, fn) {
this.listeners[name] = fn
},
}
}
function browser({ respond = () => ({ status: 200, body: {} }) } = {}) {
const calls = []
const body = fakeElement('body')
const all = () => {
const out = []
const walk = (n) => {
out.push(n)
n.children.forEach(walk)
}
walk(body)
return out
}
const document = {
body,
createElement: (tag) => fakeElement(tag),
getElementById: (id) => all().find((n) => n.id === id) || null,
}
const xhrs = []
class XMLHttpRequest {
constructor() {
this.headers = {}
this.listeners = {}
xhrs.push(this)
}
open(method, url) {
this.method = method
this.url = url
}
setRequestHeader(name, value) {
this.headers[name] = value
}
addEventListener(name, fn) {
this.listeners[name] = fn
}
send() {
this.sent = true
}
}
const ctx = {
URL,
Headers,
Response,
console,
document,
XMLHttpRequest,
location: { href: 'https://project.webklar.com/dashboard.html', origin: 'https://project.webklar.com' },
close: () => {
ctx.closed = true
},
fetch: async (input, init = {}) => {
calls.push({ url: typeof input === 'string' ? input : input.url, headers: new Headers(init.headers || {}) })
const { status, body: json } = respond(input)
return new Response(JSON.stringify(json), { status, headers: { 'content-type': 'application/json' } })
},
}
ctx.window = ctx
vm.createContext(ctx)
vm.runInContext(SRC, ctx)
const overlayText = () => {
const box = document.getElementById('kb-view-overlay')
if (!box) return null
const texts = []
const walk = (n) => {
if (n.textContent) texts.push(n.textContent)
n.children.forEach(walk)
}
walk(box)
return texts.join(' | ')
}
return { ctx, calls, xhrs, overlayText }
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
test('ohne ID kein Header', async () => {
const { ctx, calls } = browser()
await ctx.fetch('/api/projects')
assert.equal(calls[0].headers.get('x-kundenansicht'), null)
})
test('mit ID: Header bei /api/... gleicher Herkunft, nicht bei fremder Herkunft oder /img', async () => {
const { ctx, calls } = browser()
ctx.KbView.setId(ID)
assert.equal(ctx.KbView.id(), ID)
await ctx.fetch('/api/projects')
await ctx.fetch('https://example.org/api/x')
await ctx.fetch('/img/logo.png')
await ctx.fetch('https://project.webklar.com/api/news', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
assert.equal(calls[0].headers.get('x-kundenansicht'), ID)
assert.equal(calls[1].headers.get('x-kundenansicht'), null)
assert.equal(calls[2].headers.get('x-kundenansicht'), null)
assert.equal(calls[3].headers.get('x-kundenansicht'), ID)
assert.equal(calls[3].headers.get('content-type'), 'application/json', 'vorhandene Header bleiben')
})
test('409 view_replaced zeigt Overlay mit Kundennamen, Antwort bleibt lesbar', async () => {
const { ctx, overlayText } = browser({ respond: () => ({ status: 409, body: { code: 'view_replaced', kunde: 'Kunde Zwei', error: 'x' } }) })
ctx.KbView.setId(ID)
const res = await ctx.fetch('/api/projects')
assert.equal((await res.json()).code, 'view_replaced')
await settle()
assert.match(overlayText(), /Diese Ansicht wurde durch die Kundenansicht von Kunde Zwei ersetzt\./)
assert.match(overlayText(), /Tab schließen/)
})
test('401 view_expired zeigt Abgelaufen-Overlay, andere 401 nicht', async () => {
const other = browser({ respond: () => ({ status: 401, body: { error: 'Nicht angemeldet' } }) })
await other.ctx.fetch('/api/projects')
await settle()
assert.equal(other.overlayText(), null)
const { ctx, overlayText } = browser({ respond: () => ({ status: 401, body: { code: 'view_expired' } }) })
await ctx.fetch('/api/projects')
await settle()
assert.match(overlayText(), /Die Kundenansicht ist abgelaufen\. Bitte im Ticketsystem erneut öffnen\./)
})
test('Overlay-Knopf schliesst den Tab, zweiter Hinweis ersetzt den Text statt zu stapeln', async () => {
const { ctx, overlayText } = browser()
ctx.KbView.showReplaced('Kunde Zwei')
ctx.KbView.showExpired()
const box = ctx.document.getElementById('kb-view-overlay')
assert.equal(ctx.document.body.children.length, 1)
assert.match(overlayText(), /abgelaufen/)
const find = (n) => (n.tag === 'button' ? n : n.children.map(find).find(Boolean))
find(box).listeners.click()
assert.equal(ctx.closed, true)
})
test('XHR bekommt den Header ebenfalls (nur eigene /api-Adressen)', () => {
const { ctx, xhrs } = browser()
ctx.KbView.setId(ID)
const a = new ctx.XMLHttpRequest()
a.open('POST', '/api/chat/attachments')
a.send()
const b = new ctx.XMLHttpRequest()
b.open('GET', 'https://example.org/api/x')
b.send()
assert.equal(xhrs[0].headers['X-Kundenansicht'], ID)
assert.equal(xhrs[1].headers['X-Kundenansicht'], undefined)
})
+89
View File
@@ -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')
})
+209
View File
@@ -0,0 +1,209 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Waechter der Kundenansicht: Ablauf nach 2 h, Verwechslungsschutz (X-Kundenansicht), Schreibsperre ausser News
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'kundenansicht-guard-'))
const aw = await startAppwriteMock({
schema: {
customerViewLog: schemaKeys('customerViewLog'),
portalFeatures: schemaKeys('portalFeatures'),
newsPosts: schemaKeys('newsPosts'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js')
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js
const MOUNTS = [
['/api/auth', (await import('../server/routes/auth.js')).default],
['/api/projects', (await import('../server/routes/projects.js')).default],
['/api/features', (await import('../server/routes/features.js')).default],
['/api/news', (await import('../server/routes/news.js')).default],
['/api', (await import('../server/routes/customerData.js')).default],
['/api/chat', (await import('../server/routes/chat.js')).default],
['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default],
['/api/abo', (await import('../server/routes/abo.js')).default],
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
]
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS) })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const VIEW = 'a'.repeat(32)
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
async function call(method, url, { cookie, headers = {}, body } = {}) {
const res = await fetch(app.url + url, {
method,
redirect: 'manual',
headers: { ...(cookie ? { cookie } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await res.text()
let parsed = text
try {
parsed = text ? JSON.parse(text) : null
} catch {
/* Text-Antwort */
}
return { status: res.status, body: parsed, headers: res.headers }
}
/** Alle nicht-lesenden Routen der Router (router.stack), :param durch 'x' ersetzt. */
function writeRoutes(mounts) {
const routes = []
for (const [prefix, router] of mounts) {
for (const layer of router.stack) {
if (!layer.route) continue
for (const method of Object.keys(layer.route.methods)) {
if (['get', 'head', 'options', '_all'].includes(method)) continue
const sub = String(layer.route.path).replace(/:[A-Za-z]+/g, 'x')
routes.push({ method: method.toUpperCase(), path: `${prefix === '/api' ? '/api' : prefix}${sub === '/' ? '' : sub}` || '/' })
}
}
}
return routes
}
const allowed = (method, p) => KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(p))
const logEntries = () => [...aw.docs('customerViewLog').values()]
// geloeschtes Cookie: Ablauf 1970 (ein signiertes behaelt dabei einen leeren signierten Wert "s:.<sig>")
const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c))
test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => {
const routes = writeRoutes(MOUNTS)
assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`)
let checked = 0
for (const { method, path: p } of routes) {
if (allowed(method, p)) continue
const res = await call(method, p, kv())
assert.equal(res.status, 403, `${method} ${p}`)
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
checked += 1
}
assert.ok(checked >= 20, `nur ${checked} gesperrte Routen geprueft`)
})
test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => {
const ok = [
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
['POST', '/api/auth/logout'], ['POST', '/api/auth/login'],
]
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
for (const [m, p] of [['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout']]) {
assert.ok(!allowed(m, p), `${m} ${p}`)
}
})
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
const res = await call('POST', '/api/chat/read', kv())
assert.equal(res.status, 403)
})
test('News anlegen in der Kundenansicht erlaubt', async () => {
const res = await call('POST', '/api/news', { ...kv(), body: note })
assert.equal(res.status, 201)
})
test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => {
const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } })
assert.equal(res.status, 409)
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins'])
})
test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => {
const write = await call('POST', '/api/news', { cookie: kundenansichtCookie(), body: note })
assert.equal(write.status, 409)
assert.equal(write.body.code, 'view_replaced')
const read = await call('GET', '/api/news?projectId=p1', { cookie: kundenansichtCookie() })
assert.equal(read.status, 200)
})
test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async (t) => {
const at = Date.now()
t.mock.method(Date, 'now', () => at)
const viewId = 'e'.repeat(32)
const opts = kv({ viewId, viewExpiresAt: at })
const before = logEntries().filter((d) => d.action === 'abgelaufen').length
const first = await call('GET', '/api/projects', opts)
const second = await call('POST', '/api/news', { ...opts, body: note })
for (const res of [first, second]) {
assert.equal(res.status, 401)
assert.equal(res.body.code, 'view_expired')
assert.equal(res.body.error, 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.')
assert.ok(clears(res, config.cookieName), 'Portal-Cookie geloescht')
assert.ok(clears(res, 'preview_session'), 'Preview-Cookie geloescht')
}
const expired = logEntries().filter((d) => d.action === 'abgelaufen')
assert.equal(expired.length, before + 1)
assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej'])
})
test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => {
const cookie = viewAsCookie('c1')
assert.equal((await call('GET', '/api/news?projectId=p1', { cookie })).status, 200)
const write = await call('POST', '/api/news', { cookie, body: note })
assert.equal(write.status, 403)
assert.equal(write.body.code, 'view_blocked')
assert.equal((await call('POST', '/api/auth/logout', { cookie })).status, 200)
})
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {
const res = await call('POST', '/api/news', { cookie: customerCookie('c1'), body: note })
assert.equal(res.status, 201)
})
test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => {
const cookie = kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 })
assert.equal((await call('GET', '/api/auth/me', { cookie })).status, 200)
const token = createStaffPreviewToken({
purpose: 'kundenansicht',
customerId: 'c1',
staff: { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' },
})
const res = await call('GET', `/api/auth/kundenansicht?token=${token}`, { cookie })
assert.equal(res.status, 302)
assert.equal(res.headers.get('location'), '/dashboard.html')
})
// Abschluss-Review: Express routet ohne Ruecksicht auf Gross-/Kleinschreibung, der Waechter muss das auch
test('Grossschreibung umgeht den Waechter nicht: /API/... wie /api/...', async () => {
const routes = writeRoutes(MOUNTS).filter(({ method, path: p }) => !allowed(method, p))
for (const { method, path: p } of routes) {
const upper = p.replace(/^\/api\//, '/API/')
const res = await call(method, upper, kv())
assert.equal(res.status, 403, `${method} ${upper}`)
}
const legacy = await call('POST', '/API/news', { cookie: viewAsCookie('c1'), body: note })
assert.equal(legacy.status, 403)
const expired = await call('GET', '/Api/Projects', kv({ viewExpiresAt: Date.now() - 1000 }))
assert.equal(expired.status, 401)
})
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
const header = { 'x-kundenansicht': VIEW }
for (const cookie of [customerCookie('c2'), viewAsCookie('c2')]) {
const res = await call('GET', '/api/projects', { cookie, headers: header })
assert.equal(res.status, 409)
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
}
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
})
+95
View File
@@ -0,0 +1,95 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import sharp from 'sharp'
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: News-Aenderungen eines Mitarbeiters landen mit Titel im Protokoll, die des Kunden nicht
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'kundenansicht-news-'))
const aw = await startAppwriteMock({
schema: {
customerViewLog: schemaKeys('customerViewLog'),
portalFeatures: schemaKeys('portalFeatures'),
newsPosts: schemaKeys('newsPosts'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: newsRoutes } = await import('../server/routes/news.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/news': newsRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const VIEW = 'a'.repeat(32)
const staff = { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': VIEW } }
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
const call = async (method, url, { cookie, headers = {}, body } = {}) => {
const raw = Buffer.isBuffer(body)
const res = await fetch(app.url + url, {
method,
headers: { ...(cookie ? { cookie } : {}), ...(body !== undefined && !raw ? { 'content-type': 'application/json' } : {}), ...headers },
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null }
}
const logOf = (customerId) => [...aw.docs('customerViewLog').values()].filter((d) => d.customerId === customerId)
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
test('anlegen, aendern, Foto, loeschen in der Kundenansicht -> vier Protokolleintraege', async () => {
const before = logOf('c1').length
const created = await call('POST', '/api/news', { ...staff, body: note })
assert.equal(created.status, 201)
const id = created.body.$id
const patched = await call('PATCH', `/api/news/${id}`, { ...staff, body: { ...note, title: 'Neu' } })
assert.equal(patched.status, 200)
const image = await call('POST', '/api/news/image?projectId=p1', {
...staff,
headers: { ...staff.headers, 'content-type': 'application/octet-stream' },
body: await png(),
})
assert.equal(image.status, 201)
const removed = await call('DELETE', `/api/news/${id}?projectId=p1`, staff)
assert.equal(removed.status, 204)
const entries = logOf('c1').slice(before)
assert.deepEqual(entries.map((e) => [e.action, e.detail]), [
['news_angelegt', 'Hallo'],
['news_geaendert', 'Neu'],
['news_bild', ''],
['news_geloescht', 'Neu'],
])
for (const e of entries) assert.deepEqual([e.staffName, e.staffUserId], ['Andrej', 'u_staff2'])
})
test('ohne Titel: Detail sind die ersten 60 Zeichen des Textes', async () => {
const before = logOf('c1').length
const text = 'Heute frische Brötchen bis 18 Uhr und morgen geschlossen wegen Inventur, danke!'
const res = await call('POST', '/api/news', { ...staff, body: { ...note, title: '', text } })
assert.equal(res.status, 201)
assert.equal(logOf('c1').slice(before)[0].detail, text.slice(0, 60))
})
test('Kunde selbst schreibt -> kein Protokolleintrag', async () => {
const before = logOf('c1').length
const res = await call('POST', '/api/news', { cookie: customerCookie('c1'), body: note })
assert.equal(res.status, 201)
assert.equal(logOf('c1').length, before)
})
test('Protokoll scheitert -> News-Aktion trotzdem 201', async (t) => {
t.mock.method(console, 'warn', () => {})
aw.failNext({ collection: 'customerViewLog', method: 'POST', status: 500, message: 'kaputt' })
const res = await call('POST', '/api/news', { ...staff, body: note })
assert.equal(res.status, 201)
})
+14 -4
View File
@@ -294,14 +294,14 @@ test('Datenschutz: Abos, produktneutral, Stand wie termsVersion', () => {
// Produktneutral; keine Zusage einer Ereignisansicht (Plan 2 baut keine); UptimeRobot ist kein Unterauftragsverarbeiter
for (const s of ['insbesondere das Wartungs-Abo', 'Schließen Sie das Wartungs-Abo', 'im Rahmen des Wartungs-Abos',
'für das Wartungs-Abo über Stripe', 'Sie sehen diese Ereignisse', 'Unterauftragsverarbeiter']) assert.ok(!ds.includes(s), `noch da: ${s}`)
// Stand = Datum aus termsVersion (JJJJ-MM-TT-v4). Die Kopplung gilt bis zum Livegang: Task 20 trägt in beiden das
// Freigabedatum ein. Danach darf sie gelockert werden, sobald die Erklärung unabhängig von den Bedingungen geändert wird.
// Stand nicht vor dem Datum aus termsVersion (JJJJ-MM-TT-vN). Bis 30.09.2026 waren beide gleich; seit der
// Kundenansicht (01.10.2026) wird die Erklärung auch unabhängig von den Bedingungen geändert.
const m = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion nicht im Format JJJJ-MM-TT-vN: ${config.abo.termsVersion}`)
const [, y, mo, d] = m
const stand = /Stand (\d{2}\.\d{2}\.\d{4})/.exec(ds)
const stand = /Stand (\d{2})\.(\d{2})\.(\d{4})/.exec(ds)
assert.ok(stand, 'Stand fehlt')
assert.equal(stand[1], `${d}.${mo}.${y}`)
assert.ok(`${stand[3]}-${stand[2]}-${stand[1]}` >= `${y}-${mo}-${d}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
assert.ok(read('scripts/build-legal-pdfs.sh').includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
})
@@ -490,3 +490,13 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)',
assert.ok(muster.includes('[Prüfen: AVV mit WEBklar erforderlich]'))
assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
})
test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren, Protokoll und 12 Monate', () => {
const html = read(DS)
assert.ok(!html.includes('Eine Nachricht in Ihrem Namen senden können sie dabei nicht.'))
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen.'))
assert.ok(html.includes('Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.'))
assert.ok(html.includes('Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten.'))
assert.ok(html.includes('Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.'))
assert.ok(html.includes('Stand 01.10.2026'))
})
+3 -1
View File
@@ -20,7 +20,9 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: newsRoutes } = await import('../server/routes/news.js')
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
const app = await startTestApp({ '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
// Waechter wie in server/index.js: er sperrt Schreibzugriffe der alten Ansicht "Als Kunde ansehen"
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
+3
View File
@@ -173,6 +173,9 @@ export async function startAppwriteMock({ schema = {} } = {}) {
// jwt-staff = Mitarbeiter ohne Label admin, jwt-halb = JWT aus einer Sitzung ohne zweiten Faktor
if (jwt === 'jwt-staff') return [200, { $id: 'u_staff2', email: 'andrej@webklar.com', name: 'Andrej', labels: [] }]
if (jwt === 'jwt-halb') return moreFactors
// Team-Eingang: Konto-Mail gleicht einem Mitarbeiter, ist aber unbestaetigt bzw. bestaetigt (ohne userId-Treffer)
if (jwt === 'jwt-mail-ungeprueft') return [200, { $id: 'u_fremd', email: 'andrej@webklar.com', emailVerification: false, labels: [] }]
if (jwt === 'jwt-mail-geprueft') return [200, { $id: 'u_neu', email: 'nico@webklar.com', emailVerification: true, name: 'Nico', labels: [] }]
const session = sessionOf(req)
if (session) return needsMoreFactors(session) ? moreFactors : [200, publicUser(users.get(session.userId))]
return [401, { message: 'User (role: guests) missing scope (account)', code: 401, type: 'general_unauthorized_scope' }]
+16
View File
@@ -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)
+78
View File
@@ -0,0 +1,78 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { Router } from 'express'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Team-Eingang /api/team: Appwrite-JWT aus dem Ticketsystem, jeder Mitarbeiter (nicht nur Label admin)
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, rawGet } = await import('./support/testApp.js')
const { requireStaffJwt } = await import('../server/middleware/staffJwt.js')
const { corsMiddleware } = await import('../server/middleware/cors.js')
const probeRouter = Router()
probeRouter.use(requireStaffJwt)
probeRouter.get('/', (req, res) => res.json(req.session))
const app = await startTestApp({
'/': corsMiddleware(['https://ticket.webklar.com']),
'/probe': probeRouter,
})
after(() => Promise.all([app.close(), aw.close(), st.close()]))
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('employees', { $id: 'e_nico', userId: '', displayName: 'Nico', email: 'nico@webklar.com' })
const probe = (jwt) => rawGet('/probe', jwt ? { headers: { authorization: `Bearer ${jwt}` } } : {})
test('ohne, ungueltiges und halbes JWT -> 401', async () => {
for (const jwt of [null, 'jwt-falsch', 'jwt-halb']) assert.equal((await probe(jwt)).status, 401, String(jwt))
})
test('Kunde (kein Mitarbeiter, kein Label) -> 403', async () => {
const res = await probe('jwt-kunde')
assert.equal(res.status, 403)
assert.equal(res.body.error, 'Kein Mitarbeiterkonto.')
})
test('Mail gleicht Mitarbeiter, ist aber unbestaetigt -> 403', async () => {
assert.equal((await probe('jwt-mail-ungeprueft')).status, 403)
})
test('bestaetigte Mail eines Mitarbeiters ohne userId -> erlaubt', async () => {
const res = await probe('jwt-mail-geprueft')
assert.equal(res.status, 200)
assert.equal(res.body.name, 'Nico')
assert.equal(res.body.appwriteUserId, 'u_neu')
})
test('Mitarbeiter ohne Admin-Label -> erlaubt, Sitzung in Admin-Form', async () => {
const res = await probe('jwt-staff')
assert.equal(res.status, 200)
assert.deepEqual(res.body, { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' })
})
test('Label admin ohne employees-Eintrag -> erlaubt, isAdmin true', async () => {
const res = await probe('jwt-admin')
assert.equal(res.status, 200)
assert.equal(res.body.isAdmin, true)
})
test('CORS: erlaubte Herkunft bekommt die Upload-Header, fremde nichts', async () => {
const ok = await rawGet('/probe', { headers: { origin: 'https://ticket.webklar.com' } })
assert.equal(ok.headers.get('access-control-allow-origin'), 'https://ticket.webklar.com')
assert.equal(
ok.headers.get('access-control-allow-headers'),
'Authorization, Content-Type, X-File-Name, X-Attachment-Kind, X-Duration-Ms, X-Image-Width, X-Image-Height'
)
const bad = await rawGet('/probe', { headers: { origin: 'https://evil.example' } })
assert.equal(bad.headers.get('access-control-allow-origin'), null)
})
test('CORS: Preflight der erlaubten Herkunft -> 204 ohne JWT-Pruefung', async () => {
const res = await fetch(`${app.url}/probe`, { method: 'OPTIONS', headers: { origin: 'https://ticket.webklar.com' } })
assert.equal(res.status, 204)
})
+69
View File
@@ -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}`)
}
})
+121
View File
@@ -0,0 +1,121 @@
// Playwright-Checks der Kundenansicht im Dashboard: statischer Server fuer public/ + per page.route gemockte API.
// Nicht Teil von npm test. Aufruf: node test/ui/kundenansicht.ui.mjs
// Screenshots landen in /root/reports/admin-umzug-ui/.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/admin-umzug-ui'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname)
const data = await readFile(file)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(data)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const VIEW = 'a'.repeat(32)
const customer = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const ME = {
neu: { authenticated: true, role: 'admin', viewAs: true, viewId: VIEW, viewExpiresAt: Date.now() + 3600000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer },
alt: { authenticated: true, role: 'admin', viewAs: true, admin: { name: 'Kenso', email: 'kenso@webklar.com', isAdmin: true }, customer },
}
let scenario = 'neu'
let replaceAfterMe = false
const seen = { headers: [], ende: 0, chatRead: 0 }
async function mockApi(page) {
await page.route('**/api/**', async (route) => {
const req = route.request()
const p = new URL(req.url()).pathname
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (p === '/api/auth/me') return json(ME[scenario])
// nur Skript-Anfragen zaehlen: Bilder (<img src="/api/avatar.svg">) koennen keinen Header tragen und sind reine Leseanfragen
if (['fetch', 'xhr'].includes(req.resourceType())) seen.headers.push(req.headers()['x-kundenansicht'] || null)
if (replaceAfterMe && p === '/api/projects') return json({ code: 'view_replaced', kunde: 'Kunde Zwei', error: 'ersetzt' }, 409)
if (p === '/api/auth/kundenansicht/ende') {
seen.ende += 1
return json({ redirect: `${base}/zurueck-im-ticketsystem.html` })
}
if (p === '/api/chat/read') seen.chatRead += 1
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
if (p === '/api/chat/messages') return json({ messages: [], unread: 2 })
return json({})
})
}
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const browser = await chromium.launch()
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 })
const page = await ctx.newPage()
page.setDefaultTimeout(60000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await mockApi(page)
const open = async (sc, hash = '') => {
scenario = sc
seen.headers.length = 0
await page.goto('about:blank')
await page.goto(`${base}/dashboard.html${hash}`)
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
await page.waitForTimeout(400)
}
// 1) neue Kundenansicht: Banner, Kennung an den Anfragen, Chat nicht als gelesen markieren, Beenden
replaceAfterMe = false
seen.chatRead = 0
await open('neu', '#chat')
const banner = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim()
check(`${label} banner_text`, banner === 'Kundenansicht: Berta Berg · geöffnet von Andrej Beenden', banner)
check(`${label} kennung_an_anfragen`, seen.headers.length > 0 && seen.headers.every((h) => h === VIEW), JSON.stringify(seen.headers))
check(`${label} chat_nicht_gelesen`, seen.chatRead === 0, `chat/read ${seen.chatRead}x`)
await page.screenshot({ path: `${SHOTS}/${label}-banner.png` })
await Promise.all([page.waitForURL(/zurueck-im-ticketsystem/), page.click('#exit-viewas-btn')])
check(`${label} beenden_ruft_ende_und_leitet_um`, seen.ende >= 1)
// 2) ersetzte Ansicht: Overlay statt Inhalt
replaceAfterMe = true
await open('neu')
await page.waitForSelector('#kb-view-overlay', { timeout: 15000 }).catch(() => null)
const overlay = await page.textContent('#kb-view-overlay').catch(() => '')
check(`${label} overlay_ersetzt`, /Diese Ansicht wurde durch die Kundenansicht von Kunde Zwei ersetzt\./.test(overlay || ''), overlay)
await page.screenshot({ path: `${SHOTS}/${label}-ersetzt.png` })
replaceAfterMe = false
// 3) alte Ansicht aus dem Admin-Panel: altes Banner, keine Kennung
await open('alt')
const old = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim()
check(`${label} altes_banner`, old.includes('Admin-Ansicht') && old.includes('Zurück zur Admin-Übersicht'), old)
check(`${label} alt_ohne_kennung`, seen.headers.every((h) => h === null), JSON.stringify(seen.headers))
check(`${label} keine_js_fehler`, errors.length === 0, errors.join(' | '))
await ctx.close()
}
} finally {
await browser.close()
server.close()
}
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok')
process.exit(failed ? 1 : 0)