From be65baff01f7a9caa95dfd4ff521b663418a81d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:19:57 +0000 Subject: [PATCH] Plan: Admin-Umzug Etappe 1 (Fundament + Kundenansicht) Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-01-admin-umzug-etappe-1.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-admin-umzug-etappe-1.md diff --git a/docs/superpowers/plans/2026-10-01-admin-umzug-etappe-1.md b/docs/superpowers/plans/2026-10-01-admin-umzug-etappe-1.md new file mode 100644 index 0000000..be035eb --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-admin-umzug-etappe-1.md @@ -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/` (`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` (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`; wirft nie (Fehler nur `console.warn('[kundenansicht] Protokoll ...')`); kürzt `detail` auf 300 Zeichen; setzt `createdAt` = jetzt (ISO). +- Produces: `listViewLog(customerId, { limit = 200 } = {}) -> Promise>`, 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` (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` (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: , 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= -> 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`) +- 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` (`` 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: {Kunde} · 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 -- ` 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` (gibt `openUrl` zurück) + - `createPreviewLink({ subdomain, previewUrl }) -> Promise` +- Produces: `openInNewTab(getUrl: () => Promise, { win = globalThis.window } = {}) -> Promise`: ö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.