Plan: Admin-Umzug Etappe 1 (Fundament + Kundenansicht)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:19:57 +00:00
1 parent 5fb39f1a1b
commit be65baff01
1 file changed
+649
@@ -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.