Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-01-admin-umzug-etappe-1.md
T
2026-10-01 19:19:57 +00:00

45 KiB
Raw Blame History

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)
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
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

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') })
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

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"

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)

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

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)
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

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)
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
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"

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

// 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
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)
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
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

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
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

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
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

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
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"

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

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
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.