docs: consolidate and update documentation

- Remove obsolete instruction documents (PROMPT_*.md, SECURITY_FIXES.md)
- Update CLAUDE.md with security features, test suite, audit logs
- Merge Techstack_Dokumentation into INFRASTRUCTURE.md
- Update SECURITY.md with custom login route documentation
- Add changelog to TODO.md
- Update email service and data masking for SMTP error handling
- Extend test coverage for CSRF and data masking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-09 09:25:00 +00:00
parent 57fe652dfa
commit 6ccb50c5f4
38 changed files with 2195 additions and 7217 deletions

View file

@ -1,2 +1,5 @@
DATABASE_URI=mongodb://127.0.0.1/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE
# Prevent actual SMTP calls during tests or CI
EMAIL_DELIVERY_DISABLED=false

View file

@ -18,6 +18,7 @@ Multi-Tenant CMS für 4 Websites unter einer Payload CMS 3.x Instanz:
- **Reverse Proxy:** Caddy 2.10.2 mit Let's Encrypt
- **Process Manager:** PM2
- **Package Manager:** pnpm
- **Cache:** Redis (optional, mit In-Memory-Fallback)
## Architektur
@ -47,16 +48,33 @@ Internet → 37.24.237.181 → Caddy (443) → Payload (3000)
│ │ ├── Portfolios.ts
│ │ ├── PortfolioCategories.ts
│ │ ├── EmailLogs.ts
│ │ ├── AuditLogs.ts
│ │ └── ...
│ ├── app/(payload)/api/ # Custom API Routes
│ │ ├── users/login/route.ts # Custom Login mit Audit
│ │ ├── send-email/route.ts
│ │ ├── email-logs/
│ │ │ ├── export/route.ts
│ │ │ └── stats/route.ts
│ │ └── test-email/route.ts
│ ├── lib/
│ │ ├── email/ # E-Mail-System
│ │ │ ├── tenant-email-service.ts
│ │ │ └── payload-email-adapter.ts
│ │ ├── security/ # Security-Module
│ │ │ ├── rate-limiter.ts
│ │ │ ├── csrf.ts
│ │ │ ├── ip-allowlist.ts
│ │ │ └── data-masking.ts
│ │ ├── search.ts # Volltextsuche
│ │ └── redis.ts # Redis Cache Client
│ └── hooks/ # Collection Hooks
│ ├── sendFormNotification.ts
│ └── invalidateEmailCache.ts
│ ├── invalidateEmailCache.ts
│ └── auditLog.ts
├── tests/ # Test Suite
│ ├── unit/security/ # Security Unit Tests
│ └── int/ # Integration Tests
├── .env # Umgebungsvariablen
├── ecosystem.config.cjs # PM2 Config
└── .next/ # Build Output
@ -83,6 +101,11 @@ SMTP_FROM_NAME=Payload CMS
# Redis Cache
REDIS_URL=redis://localhost:6379
# Security
CSRF_SECRET=your-csrf-secret
SEND_EMAIL_ALLOWED_IPS= # Optional: Komma-separierte IPs/CIDRs
BLOCKED_IPS= # Optional: Global geblockte IPs
```
## Multi-Tenant Plugin
@ -119,6 +142,11 @@ pm2 status
pm2 logs payload
pm2 restart payload
# Tests
pnpm test # Alle Tests
pnpm test:security # Security Tests
pnpm test:coverage # Mit Coverage-Report
# Datenbank prüfen
PGPASSWORD=Finden55 psql -h 10.10.181.101 -U payload -d payload_db
```
@ -135,6 +163,7 @@ PGPASSWORD=Finden55 psql -h 10.10.181.101 -U payload -d payload_db
- **ES Modules:** package.json hat `"type": "module"`, daher PM2 Config als `.cjs`
- **Plugin ImportMap:** Nach Plugin-Änderungen `pnpm payload generate:importmap` ausführen
- **User-Tenant-Zuweisung:** Neue User müssen manuell Tenants zugewiesen bekommen
- **Admin Login:** Custom Route mit Audit-Logging, unterstützt JSON und `_payload` FormData
## Build-Konfiguration
@ -173,6 +202,34 @@ PGPASSWORD=Finden55 psql -h 10.10.181.101 -U payload -d payload_db -c "\dt *_loc
- **Admin Panel:** https://pl.c2sgmbh.de/admin
- **API:** https://pl.c2sgmbh.de/api
- **E-Mail API:** https://pl.c2sgmbh.de/api/send-email (POST, Auth erforderlich)
- **Test-E-Mail:** https://pl.c2sgmbh.de/api/test-email (POST, Admin erforderlich)
- **E-Mail Stats:** https://pl.c2sgmbh.de/api/email-logs/stats (GET, Auth erforderlich)
## Security-Features
### Rate-Limiting
Zentraler Rate-Limiter mit vordefinierten Limits:
- `publicApi`: 60 Requests/Minute
- `auth`: 5 Requests/15 Minuten (Login)
- `email`: 10 Requests/Minute
- `search`: 30 Requests/Minute
- `form`: 5 Requests/10 Minuten
### CSRF-Schutz
- Double Submit Cookie Pattern
- Origin-Header-Validierung
- Token-Endpoint: `GET /api/csrf-token`
- Admin-Panel hat eigenen CSRF-Schutz
### IP-Allowlist
- Konfigurierbar via `SEND_EMAIL_ALLOWED_IPS`
- Unterstützt IPs, CIDRs (`192.168.1.0/24`) und Wildcards (`10.10.*.*`)
- Globale Blocklist via `BLOCKED_IPS`
### Data-Masking
- Automatische Maskierung sensibler Daten in Logs
- Erkennt Passwörter, Tokens, API-Keys, SMTP-Credentials
- Safe-Logger-Factory für konsistentes Logging
## E-Mail-System
@ -236,6 +293,7 @@ PGPASSWORD=Finden55 psql -h 10.10.181.101 -U payload -d payload_db
SELECT * FROM tenants;
SELECT * FROM users_tenants;
SELECT * FROM email_logs ORDER BY created_at DESC LIMIT 10;
SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10;
\dt -- Alle Tabellen
```
@ -257,6 +315,7 @@ SELECT * FROM email_logs ORDER BY created_at DESC LIMIT 10;
| Forms | forms | Formular-Builder |
| FormSubmissions | form-submissions | Formular-Einsendungen |
| EmailLogs | email-logs | E-Mail-Protokollierung |
| AuditLogs | audit-logs | Security Audit Trail |
| CookieConfigurations | cookie-configurations | Cookie-Banner Konfiguration |
| CookieInventory | cookie-inventory | Cookie-Inventar |
| ConsentLogs | consent-logs | Consent-Protokollierung |
@ -270,4 +329,29 @@ SELECT * FROM email_logs ORDER BY created_at DESC LIMIT 10;
| SEOSettings | seo-settings | SEO-Einstellungen |
| PrivacyPolicySettings | privacy-policy-settings | Datenschutz-Einstellungen |
*Letzte Aktualisierung: 07.12.2025*
## Test Suite
```bash
# Alle Tests ausführen
pnpm test
# Security Tests
pnpm test:security
# Coverage Report
pnpm test:coverage
```
**Test Coverage Thresholds:**
- Lines: 35%
- Functions: 50%
- Branches: 65%
## Dokumentation
- `CLAUDE.md` - Diese Datei (Projekt-Übersicht)
- `docs/INFRASTRUCTURE.md` - Server-Architektur & Deployment
- `docs/anleitungen/TODO.md` - Task-Liste & Roadmap
- `docs/anleitungen/SECURITY.md` - Sicherheitsrichtlinien
*Letzte Aktualisierung: 09.12.2025*

View file

@ -62,6 +62,10 @@ Alternatively, you can use [Docker](https://www.docker.com) to spin up this temp
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
## Email testing
Security integration tests exercise the SMTP endpoints and will hang if no mail server is reachable. Set `EMAIL_DELIVERY_DISABLED=true` (default is `false`) to bypass the actual SMTP call while still logging the request. This flag is automatically honored in `NODE_ENV=test`, so CI pipelines can safely run the security test suite without external dependencies.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View file

@ -1,627 +0,0 @@
# IMPLEMENTIERUNGS-AUFTRAG: Multi-Tenant E-Mail-System
## Kontext
Du arbeitest am Payload CMS 3.x Multi-Tenant-System auf pl.c2sgmbh.de. Das System verwaltet mehrere Websites (porwoll.de, complexcaresolutions.de, gunshin.de, caroline-porwoll.de, etc.) über eine zentrale Payload-Instanz.
**Aktueller Status:** Kein E-Mail-Adapter konfiguriert. E-Mails werden nur in der Konsole ausgegeben.
**Ziel:** Vollständiges Multi-Tenant E-Mail-System mit tenant-spezifischen SMTP-Servern und Absender-Adressen.
---
## Anforderungen
### Funktionale Anforderungen
1. **Tenant-spezifische E-Mail-Konfiguration**
- Jeder Tenant kann eigene SMTP-Credentials haben
- Eigene Absender-Adresse und Absender-Name pro Tenant
- Eigene Reply-To-Adresse pro Tenant
- Fallback auf globale SMTP-Konfiguration wenn Tenant keine eigene hat
2. **Sicherheit**
- SMTP-Passwörter dürfen NICHT in API-Responses zurückgegeben werden
- Passwörter bleiben erhalten wenn Feld bei Update leer gelassen wird
- Verschlüsselte Verbindungen (TLS/SSL) unterstützen
3. **Performance**
- SMTP-Transporter cachen (nicht bei jeder E-Mail neu verbinden)
- Cache invalidieren wenn Tenant-E-Mail-Config geändert wird
4. **Integration**
- Formular-Einsendungen lösen automatisch Benachrichtigungen aus
- REST-Endpoint für manuelles E-Mail-Senden
- Logging aller gesendeten E-Mails
---
## Architektur
```
Request mit Tenant-Context
┌─────────────────┐
│ TenantEmailService │◄─── Ermittelt Tenant aus Request/Context
└────────┬────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Tenant E-Mail-Konfiguration? │
│ │
│ JA (eigener SMTP) NEIN (Fallback) │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Tenant SMTP │ │ Global SMTP │ │
│ │ z.B. smtp.... │ │ aus .env │ │
│ │ from: info@... │ │ from: noreply@ │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Implementierung
### Schritt 1: Dependencies installieren
```bash
pnpm add nodemailer
pnpm add -D @types/nodemailer
```
---
### Schritt 2: Tenants Collection erweitern
**Datei:** `src/collections/Tenants/index.ts`
Füge folgende Felder zur bestehenden Tenants Collection hinzu (als `group` Feld):
```typescript
{
name: 'email',
type: 'group',
label: 'E-Mail Konfiguration',
admin: {
description: 'SMTP-Einstellungen für diesen Tenant. Leer = globale Einstellungen.',
},
fields: [
{
type: 'row',
fields: [
{
name: 'fromAddress',
type: 'email',
label: 'Absender E-Mail',
admin: {
placeholder: 'info@domain.de',
width: '50%',
},
},
{
name: 'fromName',
type: 'text',
label: 'Absender Name',
admin: {
placeholder: 'Firmenname',
width: '50%',
},
},
],
},
{
name: 'replyTo',
type: 'email',
label: 'Antwort-Adresse (Reply-To)',
admin: {
placeholder: 'kontakt@domain.de (optional)',
},
},
{
name: 'useCustomSmtp',
type: 'checkbox',
label: 'Eigenen SMTP-Server verwenden',
defaultValue: false,
},
{
name: 'smtp',
type: 'group',
label: 'SMTP Einstellungen',
admin: {
condition: (data, siblingData) => siblingData?.useCustomSmtp,
},
fields: [
{
type: 'row',
fields: [
{
name: 'host',
type: 'text',
label: 'SMTP Host',
admin: {
placeholder: 'smtp.example.com',
width: '50%',
},
},
{
name: 'port',
type: 'number',
label: 'Port',
defaultValue: 587,
admin: {
width: '25%',
},
},
{
name: 'secure',
type: 'checkbox',
label: 'SSL/TLS',
defaultValue: false,
admin: {
width: '25%',
},
},
],
},
{
type: 'row',
fields: [
{
name: 'user',
type: 'text',
label: 'SMTP Benutzername',
admin: {
width: '50%',
},
},
{
name: 'pass',
type: 'text',
label: 'SMTP Passwort',
admin: {
width: '50%',
},
access: {
read: () => false, // Passwort nie in API-Response
},
hooks: {
beforeChange: [
({ value, originalDoc }) => {
// Behalte altes Passwort wenn Feld leer
if (!value && originalDoc?.email?.smtp?.pass) {
return originalDoc.email.smtp.pass
}
return value
},
],
},
},
],
},
],
},
],
}
```
---
### Schritt 3: E-Mail Service erstellen
**Datei:** `src/lib/email/tenant-email-service.ts`
```typescript
import nodemailer from 'nodemailer'
import type { Payload } from 'payload'
import type { Tenant } from '@/payload-types'
interface EmailOptions {
to: string | string[]
subject: string
html?: string
text?: string
replyTo?: string
attachments?: Array<{
filename: string
content: Buffer | string
contentType?: string
}>
}
// Cache für SMTP-Transporter
const transporterCache = new Map<string, nodemailer.Transporter>()
// Globaler Fallback-Transporter
function getGlobalTransporter(): nodemailer.Transporter {
const cacheKey = 'global'
if (!transporterCache.has(cacheKey)) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
transporterCache.set(cacheKey, transporter)
}
return transporterCache.get(cacheKey)!
}
// Tenant-spezifischer Transporter
function getTenantTransporter(tenant: Tenant): nodemailer.Transporter {
const smtp = tenant.email?.smtp
if (!smtp?.host || !tenant.email?.useCustomSmtp) {
return getGlobalTransporter()
}
const cacheKey = `tenant:${tenant.id}`
if (!transporterCache.has(cacheKey)) {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port || 587,
secure: smtp.secure || false,
auth: {
user: smtp.user,
pass: smtp.pass,
},
})
transporterCache.set(cacheKey, transporter)
}
return transporterCache.get(cacheKey)!
}
// Cache invalidieren
export function invalidateTenantEmailCache(tenantId: string): void {
transporterCache.delete(`tenant:${tenantId}`)
}
// Haupt-Funktion: E-Mail für Tenant senden
export async function sendTenantEmail(
payload: Payload,
tenantId: string,
options: EmailOptions
): Promise<{ success: boolean; messageId?: string; error?: string }> {
try {
// Tenant laden mit Admin-Zugriff (für SMTP-Pass)
const tenant = await payload.findByID({
collection: 'tenants',
id: tenantId,
depth: 0,
overrideAccess: true,
}) as Tenant
if (!tenant) {
throw new Error(`Tenant ${tenantId} nicht gefunden`)
}
// E-Mail-Konfiguration
const fromAddress = tenant.email?.fromAddress || process.env.SMTP_FROM_ADDRESS || 'noreply@c2sgmbh.de'
const fromName = tenant.email?.fromName || tenant.name || 'Payload CMS'
const replyTo = options.replyTo || tenant.email?.replyTo || fromAddress
// Transporter wählen
const transporter = getTenantTransporter(tenant)
// E-Mail senden
const result = await transporter.sendMail({
from: `"${fromName}" <${fromAddress}>`,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
replyTo,
subject: options.subject,
html: options.html,
text: options.text,
attachments: options.attachments,
})
console.log(`[Email] Sent to ${options.to} for tenant ${tenant.slug}: ${result.messageId}`)
return { success: true, messageId: result.messageId }
} catch (error) {
console.error(`[Email] Error for tenant ${tenantId}:`, error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
// Tenant aus Request ermitteln
export async function getTenantFromRequest(
payload: Payload,
req: Request
): Promise<Tenant | null> {
// Aus Header
const tenantSlug = req.headers.get('x-tenant-slug')
if (tenantSlug) {
const result = await payload.find({
collection: 'tenants',
where: { slug: { equals: tenantSlug } },
limit: 1,
})
return result.docs[0] as Tenant || null
}
// Aus Host-Header
const host = req.headers.get('host')?.replace(/:\d+$/, '')
if (host) {
const result = await payload.find({
collection: 'tenants',
where: { 'domains.domain': { equals: host } },
limit: 1,
})
return result.docs[0] as Tenant || null
}
return null
}
```
---
### Schritt 4: Cache-Invalidierung Hook
**Datei:** `src/hooks/invalidateEmailCache.ts`
```typescript
import type { CollectionAfterChangeHook } from 'payload'
import { invalidateTenantEmailCache } from '@/lib/email/tenant-email-service'
export const invalidateEmailCacheHook: CollectionAfterChangeHook = async ({
doc,
previousDoc,
operation,
}) => {
if (operation === 'update') {
const emailChanged = JSON.stringify(doc.email) !== JSON.stringify(previousDoc?.email)
if (emailChanged) {
invalidateTenantEmailCache(doc.id)
console.log(`[Email] Cache invalidated for tenant ${doc.slug}`)
}
}
return doc
}
```
**Hook in Tenants Collection registrieren:**
```typescript
// In src/collections/Tenants/index.ts
import { invalidateEmailCacheHook } from '@/hooks/invalidateEmailCache'
export const Tenants: CollectionConfig = {
// ...
hooks: {
afterChange: [invalidateEmailCacheHook],
},
}
```
---
### Schritt 5: Form-Submission Notification Hook
**Datei:** `src/hooks/sendFormNotification.ts`
```typescript
import type { CollectionAfterChangeHook } from 'payload'
import { sendTenantEmail } from '@/lib/email/tenant-email-service'
export const sendFormNotification: CollectionAfterChangeHook = async ({
doc,
req,
operation,
}) => {
if (operation !== 'create') return doc
const { payload } = req
// Form laden
const form = await payload.findByID({
collection: 'forms',
id: doc.form,
depth: 1,
})
// Prüfen ob Benachrichtigung aktiviert
if (!form?.notifyOnSubmission || !form.notificationEmail) {
return doc
}
// Tenant ermitteln
const tenantId = typeof form.tenant === 'string' ? form.tenant : form.tenant?.id
if (!tenantId) {
console.warn('[Forms] No tenant found for form submission')
return doc
}
// Daten formatieren
const submissionData = doc.submissionData as Array<{ field: string; value: string }>
const dataHtml = submissionData
.map(item => `<tr><td><strong>${item.field}</strong></td><td>${item.value}</td></tr>`)
.join('')
// E-Mail senden
await sendTenantEmail(payload, tenantId, {
to: form.notificationEmail,
subject: `Neue Formular-Einsendung: ${form.title}`,
html: `
<h2>Neue Einsendung über ${form.title}</h2>
<table border="1" cellpadding="8" cellspacing="0">
<tbody>${dataHtml}</tbody>
</table>
<p><small>Gesendet am ${new Date().toLocaleString('de-DE')}</small></p>
`,
})
return doc
}
```
---
### Schritt 6: REST-Endpoint für manuelles Senden
**Datei:** `src/app/(payload)/api/send-email/route.ts`
```typescript
import { getPayload } from 'payload'
import config from '@payload-config'
import { sendTenantEmail } from '@/lib/email/tenant-email-service'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
try {
const payload = await getPayload({ config })
const body = await req.json()
const { tenantId, to, subject, html, text } = body
if (!tenantId || !to || !subject) {
return NextResponse.json(
{ error: 'Missing required fields: tenantId, to, subject' },
{ status: 400 }
)
}
const result = await sendTenantEmail(payload, tenantId, {
to,
subject,
html,
text,
})
if (result.success) {
return NextResponse.json({ success: true, messageId: result.messageId })
} else {
return NextResponse.json({ success: false, error: result.error }, { status: 500 })
}
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
```
---
### Schritt 7: Environment Variables
**Datei:** `.env` (ergänzen)
```env
# Globale SMTP-Einstellungen (Fallback)
SMTP_HOST=smtp.c2sgmbh.de
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=noreply@c2sgmbh.de
SMTP_PASS=HIER_PASSWORT_EINTRAGEN
SMTP_FROM_ADDRESS=noreply@c2sgmbh.de
SMTP_FROM_NAME=C2S System
```
---
## Dateistruktur nach Implementierung
```
src/
├── collections/
│ └── Tenants/
│ └── index.ts # + email group field
├── hooks/
│ ├── invalidateEmailCache.ts # NEU
│ └── sendFormNotification.ts # NEU
├── lib/
│ └── email/
│ └── tenant-email-service.ts # NEU
└── app/
└── (payload)/
└── api/
└── send-email/
└── route.ts # NEU
```
---
## Testen
### 1. Tenant E-Mail-Config im Admin UI
1. Gehe zu Tenants → [beliebiger Tenant]
2. Scrolle zu "E-Mail Konfiguration"
3. Trage Absender-E-Mail und Name ein
4. Optional: Aktiviere "Eigenen SMTP-Server verwenden" und trage Credentials ein
5. Speichern
### 2. Test-E-Mail via API
```bash
curl -X POST https://pl.c2sgmbh.de/api/send-email \
-H "Content-Type: application/json" \
-d '{
"tenantId": "TENANT_ID_HIER",
"to": "test@example.com",
"subject": "Test E-Mail",
"html": "<h1>Hallo Welt</h1><p>Dies ist ein Test.</p>"
}'
```
### 3. Formular-Test
1. Erstelle ein Formular für einen Tenant
2. Aktiviere "Notify on Submission" und trage E-Mail ein
3. Sende eine Test-Einsendung über das Frontend
4. Prüfe ob E-Mail ankommt
---
## Wichtige Hinweise
1. **Types generieren** nach Änderung der Tenants Collection:
```bash
pnpm generate:types
```
2. **Build testen** vor Commit:
```bash
pnpm build
```
3. **SMTP-Credentials** sind sensibel - niemals in Git committen!
4. **Logging** prüfen bei Problemen:
```bash
pm2 logs payload
```
---
## Erwartetes Ergebnis
Nach erfolgreicher Implementierung:
- ✅ Jeder Tenant hat im Admin UI eine "E-Mail Konfiguration" Sektion
- ✅ Tenants ohne eigene SMTP-Config nutzen automatisch globale Einstellungen
- ✅ E-Mails werden mit korrektem Absender pro Tenant gesendet
- ✅ Formular-Einsendungen lösen automatisch Benachrichtigungen aus
- ✅ SMTP-Passwörter sind geschützt und nicht via API abrufbar
- ✅ API-Endpoint `/api/send-email` ermöglicht manuelles Senden
---
*Erstellt: 06. Dezember 2025*
*Projekt: Payload CMS Multi-Tenant*
*Server: pl.c2sgmbh.de (Development)*

View file

@ -1,109 +1,211 @@
# Payload CMS Multi-Tenant Infrastructure
> Letzte Aktualisierung: 09.12.2025
## Übersicht
Diese Dokumentation beschreibt die Infrastruktur eines Payload CMS 3.x Multi-Tenant-Systems für den Betrieb mehrerer Websites unter einer zentralen CMS-Instanz.
## Architektur
## Gesamtarchitektur
```
┌─────────────────────────────────────────────────────────────────┐
│ INTERNET │
│ │ │
│ 37.24.237.181 (Public IP) │
│ │ │
│ NAT (Proxmox) │
│ Port 80, 443 │
└────────────────────────────┼────────────────────────────────────┘
┌────────────────────────────┼────────────────────────────────────┐
│ VLAN 181 │
│ 10.10.181.0/24 │
│ │ │
│ ┌───────────────────────┴───────────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ LXC 700 │ │ LXC 701 │ │
│ │ sv-payload │ │ sv-postgres │ │
│ │ 10.10.181.100 │────────────────▶│ 10.10.181.101 │ │
│ │ │ Port 5432 │ │ │
│ │ - Caddy (80/443) │ │ - PostgreSQL 17 │ │
│ │ - Node.js 22 │ │ │ │
│ │ - Payload CMS │ │ │ │
│ │ - PM2 │ │ │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ GESAMTARCHITEKTUR │
│ │
│ ┌───────────────────────────────────────────────────────────────────────────────┐ │
│ │ LOKALE ENTWICKLUNGSUMGEBUNG │ │
│ │ (Proxmox VE Cluster) │ │
│ │ LAN: 10.10.181.0/24 │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ sv-payload │ │ sv-postgres │ │sv-dev-payload│ │sv-analytics │ │ │
│ │ │ LXC 700 │ │ LXC 701 │ │ LXC 702 │ │ LXC 703 │ │ │
│ │ │ Payload CMS │ │ PostgreSQL │ │ Next.js │ │ Umami │ │ │
│ │ │10.10.181.100│ │10.10.181.101│ │10.10.181.102│ │10.10.181.103│ │ │
│ │ │ + Redis │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ │ LOKALER INTERNETZUGANG │ │
│ │ 850 Mbps ↓ / 50 Mbps ↑ │ │
│ │ │ │
│ │ Feste IP-Adressen: │ │
│ │ 37.24.237.178 - Router │ │
│ │ 37.24.237.179 - complexcaresolutions │ │
│ │ 37.24.237.180 - Nginx Proxy Manager │ │
│ │ 37.24.237.181 - pl.c2sgmbh.de │ │
│ │ 37.24.237.182 - frei │ │
│ └───────────────────┬───────────────────┘ │
│ │ │
│ INTERNET │
│ │ │
│ ┌──────────────────────────────────┼──────────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ HETZNER 1 │ │ HETZNER 2 │ │ HETZNER 3 │ │
│ │ CCS GmbH │ │ Martin Porwoll │ │ Backend/Analytics │ │
│ │ │ │ │ │ │ │
│ │ 78.46.87.137 │ │ 94.130.141.114 │ │ 162.55.85.18 │ │
│ │ Debian 12.12 │ │ Ubuntu 24.04 │ │ Debian 13 │ │
│ │ Plesk │ │ Plesk │ │ Native │ │
│ │ │ │ │ │ │ │
│ │ Next.js Frontends │ │ Next.js Frontends │ │ ✅ Payload CMS │ │
│ │ • complexcare... │ │ • porwoll.de │ │ ✅ Umami │ │
│ │ • gunshin.de │ │ • caroline-... │ │ ✅ PostgreSQL 17 │ │
│ └─────────────────────┘ └─────────────────────┘ │ ✅ Redis Cache │ │
│ │ ✅ Claude Code │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
---
## Server-Details
### LXC 700 - sv-payload (Application Server)
### HETZNER 3 - Backend & Analytics (Produktion)
| Eigenschaft | Wert |
|-------------|------|
| IP | 10.10.181.100 |
| Öffentlich | 37.24.237.181 (via NAT) |
| OS | Debian 13 (Trixie) |
| CPU | 4 Cores |
| RAM | 4 GB |
| Disk | 40 GB |
| Domain | pl.c2sgmbh.de |
| **Hostname** | sv-hz03-backend |
| **IP-Adresse** | 162.55.85.18 |
| **Betriebssystem** | Debian 13 "Trixie" |
| **CPU** | AMD Ryzen 5 3600 (6 Cores / 12 Threads) |
| **RAM** | 64 GB DDR4 ECC |
| **Storage** | 2x 512 GB NVMe SSD (Software RAID 1) |
| **Netzwerk** | 1 Gbit/s (garantiert) |
| **Traffic** | Unbegrenzt |
| **Kosten** | ~€52/Monat |
**Installierte Software:**
- Node.js 22 LTS (via NodeSource)
- pnpm (Package Manager)
- Caddy 2.10.2 (Reverse Proxy mit automatischem SSL)
- PM2 (Process Manager)
- Payload CMS 3.x mit Next.js 15.4.7
#### Services auf Hetzner 3
**Dienste:**
- Caddy läuft als systemd service auf Port 80/443
- Payload läuft via PM2 auf Port 3000
| Service | User | Port | URL | Status |
|---------|------|------|-----|--------|
| PostgreSQL 17 | postgres | 5432 | localhost | ✅ Läuft |
| Payload CMS | payload | 3001 | https://cms.c2sgmbh.de | ✅ Läuft |
| Umami Analytics | umami | 3000 | https://analytics.c2sgmbh.de | ✅ Läuft |
| Redis Cache | redis | 6379 | localhost | ✅ Läuft |
| Nginx | root | 80/443 | Reverse Proxy | ✅ Läuft |
| Claude Code | claude | - | CLI Tool | ✅ Installiert |
### LXC 701 - sv-postgres (Database Server)
#### System-User
| User | Zweck | Home-Verzeichnis |
|------|-------|------------------|
| root | System-Administration | /root |
| payload | Payload CMS | /home/payload |
| umami | Umami Analytics | /home/umami |
| claude | Claude Code / Server-Admin | /home/claude |
---
### HETZNER 1 - Complex Care Solutions GmbH
| Eigenschaft | Wert |
|-------------|------|
| IP | 10.10.181.101 |
| Öffentlich | Nein (nur intern) |
| OS | Debian 13 (Trixie) |
| CPU | 2 Cores |
| RAM | 2 GB |
| Disk | 20 GB |
| **Eigentümer** | Complex Care Solutions GmbH |
| **IP-Adresse** | 78.46.87.137 |
| **Betriebssystem** | Debian 12.12 |
| **Control Panel** | Plesk Web Pro Edition 18.0.73 |
| **CPU** | AMD Ryzen 7 Pro 8700GE |
| **RAM** | 64 GB |
| **Storage** | 2x 512 GB NVMe SSD (Software RAID 1) |
**Datenbank:**
- PostgreSQL 17
- Database: payload_db
- User: payload
- Passwort: Finden55
- Nur erreichbar von 10.10.181.100
#### Domains auf Hetzner 1
## Verzeichnisstruktur auf sv-payload
| Domain | Zweck |
|--------|-------|
| **complexcaresolutions.de** | Hauptdomain |
| **gunshin.de** | Portfolio/Holding |
| c2sgmbh.de | Kurzform → Redirect |
| zweitmeinung-*.de | Fachgebiete → Redirect |
```
/home/payload/
├── payload-cms/ # Hauptanwendung
│ ├── src/
│ │ ├── collections/
│ │ │ ├── Users.ts
│ │ │ ├── Media.ts
│ │ │ └── Tenants.ts
│ │ ├── payload.config.ts
│ │ └── payload-types.ts
│ ├── .env # Umgebungsvariablen
│ ├── ecosystem.config.cjs # PM2 Konfiguration
│ ├── package.json
│ └── .next/ # Next.js Build Output
├── logs/
│ ├── error-0.log
│ └── out-0.log
└── ecosystem.config.cjs # PM2 Config (Symlink)
---
### HETZNER 2 - Martin Porwoll (privat)
| Eigenschaft | Wert |
|-------------|------|
| **Eigentümer** | Martin Porwoll (privat) |
| **IP-Adresse** | 94.130.141.114 |
| **Betriebssystem** | Ubuntu 24.04 LTS |
| **Control Panel** | Plesk Web Pro Edition 18.0.73 |
| **CPU** | Intel Xeon E3-1275v6 |
| **RAM** | 64 GB |
| **Storage** | 2x 512 GB NVMe SSD (Software RAID 1) |
#### Domains auf Hetzner 2
| Domain | Zweck |
|--------|-------|
| **porwoll.de** | Hauptdomain |
| **caroline-porwoll.de** | Dr. Caroline Porwoll |
---
### Lokale Infrastruktur (Proxmox) - Entwicklung
| Server | IP | Port | Funktion | OS |
|--------|-----|------|----------|-----|
| sv-payload | 10.10.181.100 | 3000 | Payload CMS (Dev) + Redis | Debian 13 |
| sv-postgres | 10.10.181.101 | 5432 | PostgreSQL (Dev) | Debian 13 |
| sv-dev-payload | 10.10.181.102 | 3001 | Next.js Frontend | Debian 13 |
| sv-analytics | 10.10.181.103 | 3000 | Umami (Dev) | Debian 13 |
#### Feste IP-Adressen (Lokal → Internet)
| IP | Verwendung |
|----|------------|
| 37.24.237.178 | Router / Gateway |
| 37.24.237.179 | complexcaresolutions.cloud |
| 37.24.237.180 | Nginx Proxy Manager |
| 37.24.237.181 | pl.c2sgmbh.de (Payload Dev) |
| 37.24.237.182 | **Frei** |
---
## Credentials
### Produktion (sv-hz03-backend)
#### PostgreSQL
| Datenbank | User | Passwort |
|-----------|------|----------|
| payload_db | payload | Suchen55 |
| umami_db | umami | Suchen55 |
#### Redis
```bash
redis-cli -h localhost -p 6379
# Kein Passwort (nur localhost)
```
## Konfigurationsdateien
#### Environment Variables - Payload (.env)
### .env (/home/payload/payload-cms/.env)
```env
DATABASE_URI=postgresql://payload:Suchen55@localhost:5432/payload_db
PAYLOAD_SECRET=hxPARlMkmv+ZdCOAMw+N4o2x4mNbERB237iDQTYXALY=
PAYLOAD_PUBLIC_SERVER_URL=https://cms.c2sgmbh.de
NEXT_PUBLIC_SERVER_URL=https://cms.c2sgmbh.de
NODE_ENV=production
PORT=3001
REDIS_HOST=localhost
REDIS_PORT=6379
```
### Entwicklung (pl.c2sgmbh.de)
#### PostgreSQL (sv-postgres)
| Datenbank | User | Passwort |
|-----------|------|----------|
| payload_db | payload | Finden55 |
#### Environment Variables (.env)
```env
DATABASE_URI=postgresql://payload:Finden55@10.10.181.101:5432/payload_db
@ -112,53 +214,11 @@ PAYLOAD_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
NEXT_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
NODE_ENV=production
PORT=3000
REDIS_HOST=localhost
REDIS_PORT=6379
```
### Caddyfile (/etc/caddy/Caddyfile)
```caddyfile
{
email deine-email@c2sgmbh.de
}
pl.c2sgmbh.de {
reverse_proxy localhost:3000
request_body {
max_size 100MB
}
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
-Server
}
encode gzip zstd
}
```
### PM2 Konfiguration (/home/payload/payload-cms/ecosystem.config.cjs)
```javascript
module.exports = {
apps: [{
name: 'payload',
cwd: '/home/payload/payload-cms',
script: 'pnpm',
args: 'start',
env: {
NODE_ENV: 'production',
PORT: 3000
},
instances: 1,
autorestart: true,
max_memory_restart: '1G',
error_file: '/home/payload/logs/error.log',
out_file: '/home/payload/logs/out.log'
}]
}
```
---
## Multi-Tenant Konzept
@ -173,7 +233,6 @@ Jeder Tenant repräsentiert eine separate Website:
| porwoll.de | porwoll | porwoll.de, www.porwoll.de |
| Complex Care Solutions GmbH | c2s | complexcaresolutions.de |
| Gunshin | gunshin | gunshin.de |
| Zweitmeinung | zweitmeinung | zweitmein.ng |
### Datenisolation
@ -189,9 +248,238 @@ tenants_domains - Domain-Zuordnungen
users_tenants - User-Mandanten-Beziehung (N:M)
```
---
## Redis Caching
### Architektur
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ REDIS CACHING STRATEGIE │
│ │
│ Request → Payload CMS → Redis Cache? │
│ │ │
│ ┌────┴────┐ │
│ HIT MISS │
│ │ │ │
│ ▼ ▼ │
│ Return PostgreSQL → Cache in Redis → Return │
│ │
│ Cache-Typen: │
│ • API Response Cache (GET /api/pages, /api/posts) │
│ • Automatische Invalidierung bei Content-Änderungen │
│ │
│ Konfiguration: │
│ • Max Memory: 2GB (Prod) / 512MB (Dev) │
│ • Eviction: allkeys-lru │
│ • TTL: 5 Minuten (Standard) │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
### Redis Befehle
```bash
# Status prüfen
redis-cli ping
# Statistiken
redis-cli info stats
# Cache-Keys anzeigen
redis-cli keys "*"
# Cache leeren
redis-cli flushdb
# Live-Monitoring
redis-cli monitor
```
---
## Deployment Workflow
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT WORKFLOW │
│ │
│ ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ ENTWICKLUNG (DEV) │ │ PRODUKTION (PROD) │ │
│ │ pl.c2sgmbh.de │ │ cms.c2sgmbh.de │ │
│ │ 37.24.237.181 │ │ 162.55.85.18 │ │
│ └──────────────────────────────────┘ └──────────────────────────────────┘ │
│ │
│ Step 1: CODE ENTWICKELN │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ cd /home/payload/payload-cms │ │
│ │ pnpm dev # Lokal testen │ │
│ │ pnpm build # Build-Test │ │
│ │ pm2 restart payload # Auf Dev-Server deployen │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ Step 2: ZU GITHUB PUSHEN │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ git add . # Alle Änderungen stagen │ │
│ │ git commit -m "feat: XYZ" # Commit erstellen │ │
│ │ git push origin main # Zu GitHub pushen │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ GITHUB REPOSITORY (PRIVAT) │ │
│ │ https://github.com/c2s-admin/cms.c2sgmbh │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ Step 3: AUF PRODUKTION DEPLOYEN │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ ssh payload@162.55.85.18 │ │
│ │ ~/deploy.sh # Automatisches Deployment │ │
│ │ │ │
│ │ Das deploy.sh Script macht: │ │
│ │ ├─ git pull origin main # Code von GitHub holen │ │
│ │ ├─ pnpm install # Dependencies aktualisieren │ │
│ │ ├─ pnpm build # Produktions-Build │ │
│ │ └─ pm2 restart payload # Service neustarten │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
### Git-Setup auf Servern
| Server | User | Remote | Auth-Methode | Status |
|--------|------|--------|--------------|--------|
| pl.c2sgmbh.de (Dev) | payload | HTTPS | GitHub CLI (`gh auth`) | ✅ Konfiguriert |
| cms.c2sgmbh.de (Prod) | payload | SSH | SSH-Key | ✅ Eingerichtet |
### Deployment-Befehle
**Entwicklungsserver → GitHub:**
```bash
cd /home/payload/payload-cms
git status
pnpm build
pm2 restart payload
git add .
git commit -m "feat: Beschreibung der Änderung"
git push origin main
```
**GitHub → Produktionsserver:**
```bash
# Option A: SSH + Deploy-Script (empfohlen)
ssh payload@162.55.85.18 '~/deploy.sh'
# Option B: Manuelles SSH-Login
ssh payload@162.55.85.18
cd ~/payload-cms
git pull origin main
pnpm install
pnpm build
pm2 restart payload
```
---
## Backup
### Backup-Script (~/backup.sh)
```bash
#!/bin/bash
set -e
BACKUP_DIR=~/backups
DATE=$(date +%Y-%m-%d_%H-%M-%S)
RETENTION_DAYS=7
mkdir -p $BACKUP_DIR
# PostgreSQL Backup
PGPASSWORD=Suchen55 pg_dump -h localhost -U payload payload_db > $BACKUP_DIR/payload_db_$DATE.sql
gzip $BACKUP_DIR/payload_db_$DATE.sql
# Alte Backups löschen
find $BACKUP_DIR -name "*.sql.gz" -mtime +$RETENTION_DAYS -delete
```
### Cronjob (täglich 3:00 Uhr)
```
0 3 * * * /home/payload/backup.sh >> /home/payload/backups/backup.log 2>&1
```
---
## Service-Management
### PM2 Befehle
```bash
pm2 status # Status
pm2 logs payload # Logs
pm2 restart payload # Neustart
pm2 save # Autostart speichern
```
### Systemd Services
```bash
# PostgreSQL
systemctl status postgresql
systemctl restart postgresql
# Nginx
systemctl status nginx
systemctl restart nginx
nginx -t # Config testen
# Redis
systemctl status redis-server
systemctl restart redis-server
```
---
## URLs Übersicht
| Service | Entwicklung | Produktion |
|---------|-------------|------------|
| Payload Admin | https://pl.c2sgmbh.de/admin | https://cms.c2sgmbh.de/admin |
| Payload API | https://pl.c2sgmbh.de/api | https://cms.c2sgmbh.de/api |
| Umami | - | https://analytics.c2sgmbh.de |
---
## SSH Schnellzugriff
```bash
# Produktion (Hetzner 3)
ssh root@162.55.85.18 # Root
ssh payload@162.55.85.18 # Payload User
ssh umami@162.55.85.18 # Umami User
ssh claude@162.55.85.18 # Claude Code
# Hetzner Server
ssh root@78.46.87.137 # Hetzner 1 (CCS)
ssh root@94.130.141.114 # Hetzner 2 (Porwoll)
# Entwicklung (Proxmox)
ssh payload@10.10.181.100 # sv-payload
ssh root@10.10.181.101 # sv-postgres
```
---
## Netzwerk & Firewall
### UFW Regeln auf sv-payload
### UFW Regeln auf sv-payload (Dev)
```bash
22/tcp ALLOW 10.10.181.0/24 # SSH aus VLAN
@ -199,79 +487,69 @@ users_tenants - User-Mandanten-Beziehung (N:M)
443/tcp ALLOW Anywhere # HTTPS
```
### UFW Regeln auf sv-postgres
### UFW Regeln auf sv-postgres (Dev)
```bash
22/tcp ALLOW 10.10.181.0/24 # SSH aus VLAN
5432/tcp ALLOW 10.10.181.100 # PostgreSQL nur von Payload
```
### UFW Regeln auf sv-hz03-backend (Prod)
```bash
22/tcp ALLOW Anywhere # SSH
80/tcp ALLOW Anywhere # HTTP
443/tcp ALLOW Anywhere # HTTPS
```
---
## SSL Zertifikate
| Domain | Anbieter | Status |
|--------|----------|--------|
| pl.c2sgmbh.de | Let's Encrypt (Caddy) | Auto-Renewal |
| cms.c2sgmbh.de | Let's Encrypt (Certbot) | Auto-Renewal |
| analytics.c2sgmbh.de | Let's Encrypt (Certbot) | Auto-Renewal |
---
## Tech Stack
| Komponente | Technologie | Version |
|------------|-------------|---------|
| CMS | Payload CMS | 3.x |
| Framework | Next.js | 15.4.7 |
| Runtime | Node.js | 22.x |
| Datenbank | PostgreSQL | 17 |
| Cache | Redis | 7.x |
| Analytics | Umami | 3.x |
| Process Manager | PM2 | Latest |
| Package Manager | pnpm | Latest |
| Reverse Proxy (Dev) | Caddy | 2.10.2 |
| Reverse Proxy (Prod) | Nginx | Latest |
| SSL | Let's Encrypt | - |
---
## DSGVO-Konformität
Die Architektur wurde bewusst ohne Cloudflare designed:
- Keine US-Dienste im Datenpfad für Admin-Zugriffe
- Direkte öffentliche IP statt Proxy
- Keine Auftragsverarbeiter-Verträge für CDN nötig
- Redakteur-IPs und Sessions bleiben in DE
## Wichtige Befehle
---
### Payload Management
## Checkliste nach Deployment
```bash
# Als payload User
su - payload
cd ~/payload-cms
- [ ] `pm2 status` - Alle Prozesse online?
- [ ] `redis-cli ping` - Redis antwortet?
- [ ] Admin Panel erreichbar?
- [ ] `pm2 logs payload --lines 10` - Keine Fehler?
# Entwicklung
pnpm dev
---
# Build für Production
pnpm build
# Migrationen
pnpm payload migrate:create
pnpm payload migrate
# ImportMap generieren (nach Plugin-Änderungen)
pnpm payload generate:importmap
```
### PM2 Management
```bash
pm2 status
pm2 logs payload
pm2 restart payload
pm2 stop payload
pm2 start ecosystem.config.cjs
```
### Caddy Management
```bash
sudo systemctl status caddy
sudo systemctl restart caddy
sudo caddy validate --config /etc/caddy/Caddyfile
```
### Datenbank
```bash
# Von sv-payload aus
PGPASSWORD=Finden55 psql -h 10.10.181.101 -U payload -d payload_db
# Tabellen anzeigen
\dt
# Tenants abfragen
SELECT * FROM tenants;
SELECT * FROM users_tenants;
```
## Zugriff
- **Admin Panel**: https://pl.c2sgmbh.de/admin
- **API**: https://pl.c2sgmbh.de/api
- **SSH Payload Server**: ssh root@10.10.181.100 (aus VLAN 181)
- **SSH Postgres Server**: ssh root@10.10.181.101 (aus VLAN 181)
*Stand: 09. Dezember 2025*

View file

@ -1,173 +0,0 @@
# Payload CMS Multi-Tenant - Projektstatus
**Stand:** 26. November 2025, 21:00 Uhr
## Zusammenfassung
Das Payload CMS Multi-Tenant-System ist funktionsfähig installiert und läuft. Das Admin-Panel ist erreichbar unter https://pl.c2sgmbh.de/admin
## ✅ Abgeschlossen
### Infrastruktur
- [x] LXC Container 700 (sv-payload) erstellt und konfiguriert
- [x] LXC Container 701 (sv-postgres) erstellt und konfiguriert
- [x] Netzwerk VLAN 181 eingerichtet
- [x] NAT-Regel für öffentliche IP 37.24.237.181 konfiguriert
- [x] DNS pl.c2sgmbh.de → 37.24.237.181 (ohne Cloudflare)
### PostgreSQL 17
- [x] Installation auf sv-postgres
- [x] Datenbank payload_db erstellt
- [x] User payload mit Passwort konfiguriert
- [x] Remote-Zugriff nur von 10.10.181.100 erlaubt
- [x] Firewall konfiguriert
### Payload CMS
- [x] Node.js 22 LTS installiert
- [x] pnpm installiert
- [x] Payload CMS 3.x mit blank Template erstellt
- [x] PostgreSQL-Adapter konfiguriert
- [x] Umgebungsvariablen gesetzt
- [x] Datenbank-Migrationen ausgeführt
- [x] Production Build erstellt
### Caddy Reverse Proxy
- [x] Caddy 2.10.2 installiert
- [x] Let's Encrypt SSL-Zertifikat automatisch geholt
- [x] Reverse Proxy zu localhost:3000 konfiguriert
- [x] Security Headers gesetzt
- [x] Gzip/Zstd Kompression aktiviert
### PM2 Process Management
- [x] PM2 installiert
- [x] ecosystem.config.cjs konfiguriert (als CommonJS wegen ES Module)
- [x] Autostart bei Systemboot eingerichtet
- [x] Logging konfiguriert
### Multi-Tenant Plugin
- [x] @payloadcms/plugin-multi-tenant 3.65.0 installiert
- [x] Plugin in payload.config.ts konfiguriert
- [x] Tenants Collection erstellt
- [x] ImportMap generiert
- [x] Build mit Plugin erfolgreich
## 📊 Aktuelle Daten
### Tenants
| ID | Name | Slug | Status |
|----|------|------|--------|
| 1 | porwoll.de | porwoll | ✅ Angelegt |
| 4 | Complex Care Solutions GmbH | c2s | ✅ Angelegt |
| 5 | Gunshin | gunshin | ✅ Angelegt |
| - | Zweitmeinung | zweitmeinung | ⏳ Noch anzulegen |
### Users
| ID | Email | Tenants |
|----|-------|---------|
| 1 | martin.porwoll@complexcaresolutions.de | porwoll, c2s, gunshin |
### Datenbank-Status
```sql
-- Tenants
SELECT id, name, slug FROM tenants;
-- Ergebnis: 3 Tenants (IDs 1, 4, 5)
-- User-Tenant-Zuordnung
SELECT * FROM users_tenants;
-- Ergebnis: User 1 ist Tenants 1, 4, 5 zugeordnet
```
## ⚠️ Bekannte Probleme
### 1. Tenant-Anzeige im Admin
**Problem:** In der Tenants-Übersicht wird nur der erste Tenant (porwoll.de) angezeigt, obwohl alle drei in der Datenbank existieren und dem User zugeordnet sind.
**Mögliche Ursachen:**
- Session/Cache-Problem
- Plugin-Filter-Logik
**Workaround:** Ausloggen und neu einloggen, Hard-Refresh (Ctrl+Shift+R)
### 2. Manuelle User-Tenant-Zuweisung
**Problem:** Bei der initialen Installation musste die User-Tenant-Beziehung manuell per SQL erstellt werden.
**Lösung für neue User:** Im Admin unter Users → [User] → Tenants-Feld sollte die Zuweisung möglich sein.
## 🔜 Nächste Schritte
### Kurzfristig
1. [ ] Vierten Tenant "Zweitmeinung" anlegen (slug: zweitmeinung)
2. [ ] Tenant-Anzeige-Problem debuggen
3. [ ] Domains zu Tenants hinzufügen
4. [ ] Claude Code CLI auf sv-payload installieren
### Mittelfristig
5. [ ] Content Collections erstellen (Pages, Posts, etc.)
6. [ ] Frontend Next.js Apps für jede Domain aufsetzen
7. [ ] Media Upload testen
8. [ ] Backup-Strategie implementieren
### Langfristig
9. [ ] Email-Adapter konfigurieren (aktuell: Console-Output)
10. [ ] Weitere Redakteur-Accounts anlegen
11. [ ] Monitoring einrichten
12. [ ] CI/CD Pipeline aufsetzen
## 📁 Wichtige Dateien
```
/home/payload/payload-cms/
├── src/payload.config.ts # Haupt-Konfiguration
├── src/collections/
│ ├── Users.ts
│ ├── Media.ts
│ └── Tenants.ts
├── .env # Umgebungsvariablen
└── ecosystem.config.cjs # PM2 Config
/etc/caddy/Caddyfile # Reverse Proxy Config
/var/log/caddy/ # Caddy Logs
/home/payload/logs/ # PM2/Payload Logs
```
## 🔧 Schnellbefehle
```bash
# Status prüfen
pm2 status
pm2 logs payload --lines 20
# Neustart nach Änderungen
cd /home/payload/payload-cms
pnpm build
pm2 restart payload
# Datenbank prüfen
PGPASSWORD=Finden55 psql -h 10.10.181.101 -U payload -d payload_db -c "SELECT * FROM tenants;"
# Caddy neu laden
sudo systemctl reload caddy
```
## 📞 Zugangsdaten
| Service | URL/Host | Credentials |
|---------|----------|-------------|
| Admin Panel | https://pl.c2sgmbh.de/admin | martin.porwoll@complexcaresolutions.de |
| PostgreSQL | 10.10.181.101:5432 | payload / Finden55 |
| SSH sv-payload | 10.10.181.100 | root |
| SSH sv-postgres | 10.10.181.101 | root |
## 📝 Änderungsprotokoll
### 26.11.2025
- Initial Setup komplett
- PostgreSQL 17 auf separatem LXC
- Payload CMS 3.x mit Multi-Tenant Plugin
- Caddy mit Let's Encrypt SSL
- PM2 Process Management
- 3 von 4 Tenants angelegt
- User-Tenant-Zuweisung manuell per SQL

View file

@ -1,853 +0,0 @@
# PROMPT: Consent Management System - Payload Backend
## Kontext
Du arbeitest auf dem Server **sv-payload** (10.10.181.100) im Verzeichnis `/home/payload/payload-cms`.
Dieses Projekt implementiert ein DSGVO-konformes Consent Management System gemäß Spezifikation SAS v2.6. Das System ist Multi-Tenant-fähig und nutzt die bestehende Tenants-Collection.
## Referenz-Dokument
Basis: **Systemarchitektur-Spezifikation v2.6 (Implementation Master)**
## Aufgabe
Erstelle drei neue Collections und die zugehörigen Hooks für das Consent Management:
1. **CookieConfigurations** - Mandantenspezifische Banner-Konfiguration
2. **CookieInventory** - Cookie-Dokumentation für Datenschutzerklärung
3. **ConsentLogs** - WORM Audit-Trail für Einwilligungen
---
## Schritt 1: Collection - CookieConfigurations
Erstelle `src/collections/CookieConfigurations.ts`:
```typescript
import type { CollectionConfig } from 'payload'
export const CookieConfigurations: CollectionConfig = {
slug: 'cookie-configurations',
admin: {
useAsTitle: 'title',
group: 'Consent Management',
description: 'Cookie-Banner Konfiguration pro Tenant',
},
access: {
// Öffentlich lesbar für Frontend-Initialisierung
read: () => true,
// Nur authentifizierte User können bearbeiten
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
unique: true,
admin: {
description: 'Jeder Tenant kann nur eine Konfiguration haben',
},
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Cookie-Einstellungen',
admin: {
description: 'Interner Titel zur Identifikation',
},
},
{
name: 'revision',
type: 'number',
required: true,
defaultValue: 1,
admin: {
description: 'Bei inhaltlichen Änderungen erhöhen → erzwingt erneuten Consent bei allen Nutzern',
},
},
{
name: 'enabledCategories',
type: 'select',
hasMany: true,
required: true,
defaultValue: ['necessary', 'analytics'],
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
admin: {
description: 'Welche Kategorien sollen im Banner angezeigt werden?',
},
},
{
name: 'translations',
type: 'group',
fields: [
{
name: 'de',
type: 'group',
label: 'Deutsch',
fields: [
{
name: 'bannerTitle',
type: 'text',
defaultValue: 'Wir respektieren Ihre Privatsphäre',
},
{
name: 'bannerDescription',
type: 'textarea',
defaultValue: 'Diese Website verwendet Cookies, um Ihnen die bestmögliche Erfahrung zu bieten. Sie können Ihre Einstellungen jederzeit anpassen.',
},
{
name: 'acceptAllButton',
type: 'text',
defaultValue: 'Alle akzeptieren',
},
{
name: 'acceptNecessaryButton',
type: 'text',
defaultValue: 'Nur notwendige',
},
{
name: 'settingsButton',
type: 'text',
defaultValue: 'Einstellungen',
},
{
name: 'saveButton',
type: 'text',
defaultValue: 'Auswahl speichern',
},
{
name: 'privacyPolicyUrl',
type: 'text',
defaultValue: '/datenschutz',
admin: {
description: 'Link zur Datenschutzerklärung',
},
},
{
name: 'categoryLabels',
type: 'group',
fields: [
{
name: 'necessary',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Notwendig' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies sind für die Grundfunktionen der Website erforderlich.' },
],
},
{
name: 'functional',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Funktional' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.' },
],
},
{
name: 'analytics',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Statistik' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.' },
],
},
{
name: 'marketing',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Marketing' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.' },
],
},
],
},
],
},
],
},
{
name: 'styling',
type: 'group',
admin: {
description: 'Optionale Anpassung des Banner-Designs',
},
fields: [
{
name: 'position',
type: 'select',
defaultValue: 'bottom',
options: [
{ label: 'Unten', value: 'bottom' },
{ label: 'Oben', value: 'top' },
{ label: 'Mitte (Modal)', value: 'middle' },
],
},
{
name: 'theme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dunkel', value: 'dark' },
{ label: 'Hell', value: 'light' },
{ label: 'Auto (System)', value: 'auto' },
],
},
],
},
],
}
```
---
## Schritt 2: Collection - CookieInventory
Erstelle `src/collections/CookieInventory.ts`:
```typescript
import type { CollectionConfig } from 'payload'
export const CookieInventory: CollectionConfig = {
slug: 'cookie-inventory',
admin: {
useAsTitle: 'name',
group: 'Consent Management',
description: 'Dokumentation aller verwendeten Cookies für die Datenschutzerklärung',
defaultColumns: ['name', 'provider', 'category', 'duration', 'tenant'],
},
access: {
// Öffentlich lesbar für Datenschutzerklärung
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
admin: {
description: 'Zuordnung zum Mandanten',
},
},
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Technischer Name des Cookies (z.B. "_ga", "cc_cookie")',
},
},
{
name: 'provider',
type: 'text',
required: true,
admin: {
description: 'Anbieter/Setzer des Cookies (z.B. "Google LLC", "Eigene Website")',
},
},
{
name: 'category',
type: 'select',
required: true,
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
},
{
name: 'duration',
type: 'text',
required: true,
admin: {
description: 'Speicherdauer (z.B. "Session", "1 Jahr", "2 Jahre")',
},
},
{
name: 'description',
type: 'textarea',
required: true,
admin: {
description: 'Verständliche Erklärung des Zwecks für Endnutzer',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
admin: {
description: 'Wird dieser Cookie aktuell verwendet?',
},
},
],
}
```
---
## Schritt 3: Collection - ConsentLogs (WORM)
Erstelle `src/collections/ConsentLogs.ts`:
```typescript
import type { CollectionConfig } from 'payload'
import crypto from 'crypto'
// Helper: Täglicher Salt für IP-Anonymisierung
const getDailySalt = (tenantId: string): string => {
const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
const pepper = process.env.IP_ANONYMIZATION_PEPPER || 'default-pepper-change-me'
return crypto.createHash('sha256').update(`${pepper}-${tenantId}-${date}`).digest('hex')
}
// Helper: IP anonymisieren
const anonymizeIp = (ip: string, tenantId: string): string => {
const salt = getDailySalt(tenantId)
return crypto.createHmac('sha256', salt).update(ip).digest('hex').substring(0, 32)
}
// Helper: IP aus Request extrahieren
const extractIp = (req: any): string => {
const forwarded = req.headers?.['x-forwarded-for']
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim()
}
if (Array.isArray(forwarded)) {
return forwarded[0]
}
return req.socket?.remoteAddress || req.ip || 'unknown'
}
export const ConsentLogs: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'consentId',
group: 'Consent Management',
description: 'WORM Audit-Trail für Cookie-Einwilligungen (unveränderbar)',
defaultColumns: ['consentId', 'tenant', 'categories', 'revision', 'createdAt'],
},
// Keine Versionierung/Drafts für Performance bei hohem Schreibvolumen
versions: false,
access: {
// Erstellen nur mit API-Key (wird in Hook geprüft)
create: ({ req }) => {
const apiKey = req.headers?.['x-api-key']
const validKey = process.env.CONSENT_LOGGING_API_KEY
return apiKey === validKey
},
// Lesen nur für authentifizierte Admin-User
read: ({ req }) => !!req.user,
// WORM: Keine Updates erlaubt
update: () => false,
// WORM: Keine Deletes über API (nur via Retention Job)
delete: () => false,
},
hooks: {
beforeChange: [
({ data, req, operation }) => {
if (operation !== 'create') return data
// 1. Server-generierte Consent-ID (Trust Boundary)
data.consentId = crypto.randomUUID()
// 2. IP anonymisieren
const rawIp = data.ip || extractIp(req)
const tenantId = typeof data.tenant === 'object' ? data.tenant.id : data.tenant
data.anonymizedIp = anonymizeIp(rawIp, String(tenantId))
// Rohe IP entfernen (nie speichern!)
delete data.ip
// 3. Ablaufdatum setzen (3 Jahre Retention)
const threeYearsFromNow = new Date()
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3)
data.expiresAt = threeYearsFromNow.toISOString()
// 4. User Agent kürzen (Datensparsamkeit)
if (data.userAgent && data.userAgent.length > 500) {
data.userAgent = data.userAgent.substring(0, 500)
}
return data
},
],
},
fields: [
{
name: 'consentId',
type: 'text',
required: true,
unique: true,
admin: {
readOnly: true,
description: 'Server-generierte eindeutige ID',
},
},
{
name: 'clientRef',
type: 'text',
admin: {
readOnly: true,
description: 'Client-seitige Referenz (Cookie-UUID) für Traceability',
},
},
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'categories',
type: 'json',
required: true,
admin: {
readOnly: true,
description: 'Akzeptierte Kategorien zum Zeitpunkt der Einwilligung',
},
},
{
name: 'revision',
type: 'number',
required: true,
admin: {
readOnly: true,
description: 'Version der Konfiguration zum Zeitpunkt der Zustimmung',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
readOnly: true,
description: 'Browser/Device (für Forensik und Bot-Erkennung)',
},
},
{
name: 'anonymizedIp',
type: 'text',
admin: {
readOnly: true,
description: 'HMAC-Hash der IP (täglich rotierender, tenant-spezifischer Salt)',
},
},
{
name: 'expiresAt',
type: 'date',
required: true,
admin: {
readOnly: true,
description: 'Automatische Löschung nach 3 Jahren',
date: {
pickerAppearance: 'dayOnly',
},
},
},
],
}
```
---
## Schritt 4: Retention Job (Automatische Löschung)
Erstelle `src/jobs/consentRetentionJob.ts`:
```typescript
import type { Payload } from 'payload'
/**
* Consent Retention Job
*
* Löscht abgelaufene ConsentLogs gemäß DSGVO Art. 5 Abs. 1e (Speicherbegrenzung).
* Sollte täglich via Cron ausgeführt werden.
*/
export const runConsentRetentionJob = async (payload: Payload): Promise<void> => {
const now = new Date().toISOString()
try {
// Finde abgelaufene Einträge
const expired = await payload.find({
collection: 'consent-logs',
where: {
expiresAt: {
less_than: now,
},
},
limit: 1000, // Batch-Größe
})
if (expired.docs.length === 0) {
console.log('[ConsentRetention] Keine abgelaufenen Einträge gefunden.')
return
}
console.log(`[ConsentRetention] ${expired.docs.length} abgelaufene Einträge gefunden. Lösche...`)
// Lösche jeden Eintrag einzeln (WORM-Bypass via direktem DB-Zugriff)
// Da delete: () => false gesetzt ist, müssen wir den DB-Adapter direkt nutzen
for (const doc of expired.docs) {
await payload.db.deleteOne({
collection: 'consent-logs',
where: { id: { equals: doc.id } },
})
}
console.log(`[ConsentRetention] ${expired.docs.length} Einträge gelöscht.`)
// Falls mehr als 1000 Einträge: Rekursiv weitermachen
if (expired.docs.length === 1000) {
console.log('[ConsentRetention] Weitere Einträge vorhanden, führe nächsten Batch aus...')
await runConsentRetentionJob(payload)
}
} catch (error) {
console.error('[ConsentRetention] Fehler:', error)
throw error
}
}
```
---
## Schritt 5: Job-Scheduler einrichten
Erstelle `src/jobs/scheduler.ts`:
```typescript
import cron from 'node-cron'
import type { Payload } from 'payload'
import { runConsentRetentionJob } from './consentRetentionJob'
/**
* Initialisiert alle Scheduled Jobs
*/
export const initScheduledJobs = (payload: Payload): void => {
// Consent Retention: Täglich um 03:00 Uhr
cron.schedule('0 3 * * *', async () => {
console.log('[Scheduler] Starte Consent Retention Job...')
try {
await runConsentRetentionJob(payload)
} catch (error) {
console.error('[Scheduler] Consent Retention Job fehlgeschlagen:', error)
}
}, {
timezone: 'Europe/Berlin'
})
console.log('[Scheduler] Scheduled Jobs initialisiert.')
}
```
---
## Schritt 6: Collections registrieren
Aktualisiere `src/payload.config.ts`:
```typescript
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import path from 'path'
import { fileURLToPath } from 'url'
// Existing Collections
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Tenants } from './collections/Tenants'
import { Pages } from './collections/Pages'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { SocialLinks } from './collections/SocialLinks'
// NEW: Consent Management Collections
import { CookieConfigurations } from './collections/CookieConfigurations'
import { CookieInventory } from './collections/CookieInventory'
import { ConsentLogs } from './collections/ConsentLogs'
// Existing Globals
import { SiteSettings } from './globals/SiteSettings'
import { Navigation } from './globals/Navigation'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
},
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de',
cors: [
'http://localhost:3000',
'http://localhost:3001',
'http://10.10.181.102:3000',
'http://10.10.181.102:3001',
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
'https://complexcaresolutions.de',
'https://www.complexcaresolutions.de',
'https://gunshin.de',
'https://www.gunshin.de',
],
csrf: [
'http://localhost:3000',
'http://localhost:3001',
'http://10.10.181.102:3000',
'http://10.10.181.102:3001',
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
'https://complexcaresolutions.de',
'https://www.complexcaresolutions.de',
'https://gunshin.de',
'https://www.gunshin.de',
],
collections: [
Users,
Media,
Tenants,
Pages,
Posts,
Categories,
SocialLinks,
// NEW: Consent Management
CookieConfigurations,
CookieInventory,
ConsentLogs,
],
globals: [SiteSettings, Navigation],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
plugins: [
multiTenantPlugin({
tenantsSlug: 'tenants',
collections: {
media: {},
pages: {},
posts: {},
categories: {},
'social-links': {},
// NEW: Consent Collections mit Tenant-Scoping
'cookie-configurations': {},
'cookie-inventory': {},
'consent-logs': {},
},
}),
],
})
```
---
## Schritt 7: Scheduler in Server einbinden
Aktualisiere `src/server.ts` (oder erstelle, falls nicht vorhanden):
```typescript
import express from 'express'
import payload from 'payload'
import { initScheduledJobs } from './jobs/scheduler'
const app = express()
const start = async () => {
await payload.init({
secret: process.env.PAYLOAD_SECRET || '',
express: app,
onInit: async () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
// Scheduled Jobs starten
initScheduledJobs(payload)
},
})
app.listen(3000)
}
start()
```
---
## Schritt 8: Dependencies installieren
```bash
cd /home/payload/payload-cms
pnpm add node-cron
pnpm add -D @types/node-cron
```
---
## Schritt 9: Environment Variables
Füge zu `.env` hinzu:
```env
# Consent Management
CONSENT_LOGGING_API_KEY=GENERIERE_EINEN_SICHEREN_KEY_HIER
IP_ANONYMIZATION_PEPPER=GENERIERE_EINEN_ANDEREN_SICHEREN_KEY_HIER
```
Generiere sichere Keys:
```bash
# Auf sv-payload
openssl rand -hex 32 # Für CONSENT_LOGGING_API_KEY
openssl rand -hex 32 # Für IP_ANONYMIZATION_PEPPER
```
---
## Schritt 10: Migrationen und Build
```bash
cd /home/payload/payload-cms
# TypeScript Types generieren
pnpm payload generate:types
# Migrationen erstellen und ausführen
pnpm payload migrate:create
pnpm payload migrate
# Build
pnpm build
# PM2 neustarten
pm2 restart payload
```
---
## Schritt 11: Verifizierung
### API-Endpoints testen
```bash
# CookieConfigurations (öffentlich)
curl -s http://localhost:3000/api/cookie-configurations | jq
# CookieInventory (öffentlich)
curl -s http://localhost:3000/api/cookie-inventory | jq
# ConsentLogs erstellen (mit API-Key)
curl -X POST http://localhost:3000/api/consent-logs \
-H "Content-Type: application/json" \
-H "x-api-key: DEIN_CONSENT_LOGGING_API_KEY" \
-d '{
"clientRef": "test-client-123",
"tenant": 1,
"categories": ["necessary", "analytics"],
"revision": 1,
"userAgent": "Mozilla/5.0 Test",
"ip": "192.168.1.100"
}' | jq
# ConsentLogs lesen (nur mit Admin-Auth)
# → Über Admin Panel: https://pl.c2sgmbh.de/admin/collections/consent-logs
```
### Prüfpunkte
- [ ] CookieConfigurations Collection erscheint im Admin unter "Consent Management"
- [ ] CookieInventory Collection erscheint im Admin
- [ ] ConsentLogs Collection erscheint im Admin (nur lesbar)
- [ ] ConsentLogs: Update-Button ist deaktiviert
- [ ] ConsentLogs: Delete-Button ist deaktiviert
- [ ] API-Erstellung von ConsentLogs funktioniert nur mit korrektem API-Key
- [ ] `consentId` wird serverseitig generiert (nicht vom Client überschreibbar)
- [ ] `anonymizedIp` ist ein Hash, keine echte IP
- [ ] `expiresAt` wird automatisch auf +3 Jahre gesetzt
---
## Schritt 12: Initiale Daten anlegen (Optional)
### Cookie-Konfiguration für porwoll.de
Im Admin Panel unter **Consent Management → Cookie Configurations → Create**:
| Feld | Wert |
|------|------|
| Tenant | porwoll.de |
| Title | Cookie-Einstellungen porwoll.de |
| Revision | 1 |
| Enabled Categories | Notwendig, Statistik |
### Cookie-Inventory für porwoll.de
Im Admin Panel unter **Consent Management → Cookie Inventory → Create**:
| Name | Provider | Category | Duration | Description |
|------|----------|----------|----------|-------------|
| cc_cookie | Eigene Website | Notwendig | 1 Jahr | Speichert Ihre Cookie-Einstellungen |
| _ga | Google LLC | Statistik | 2 Jahre | Google Analytics - Unterscheidung von Nutzern |
| _ga_* | Google LLC | Statistik | 2 Jahre | Google Analytics - Session-Daten |
---
## Zusammenfassung der erstellten Dateien
| Datei | Beschreibung |
|-------|--------------|
| `src/collections/CookieConfigurations.ts` | Banner-Konfiguration pro Tenant |
| `src/collections/CookieInventory.ts` | Cookie-Dokumentation |
| `src/collections/ConsentLogs.ts` | WORM Audit-Trail mit Hooks |
| `src/jobs/consentRetentionJob.ts` | Automatische Löschung nach 3 Jahren |
| `src/jobs/scheduler.ts` | Cron-Scheduler für Jobs |
## Neue Environment Variables
| Variable | Beschreibung |
|----------|--------------|
| `CONSENT_LOGGING_API_KEY` | API-Key für Frontend-zu-Backend Logging |
| `IP_ANONYMIZATION_PEPPER` | Geheimer Pepper für IP-Hashing |
## API-Endpoints
| Endpoint | Method | Auth | Beschreibung |
|----------|--------|------|--------------|
| `/api/cookie-configurations` | GET | Public | Banner-Config abrufen |
| `/api/cookie-inventory` | GET | Public | Cookie-Liste für Datenschutz |
| `/api/consent-logs` | POST | x-api-key | Consent loggen |
| `/api/consent-logs` | GET | Admin | Logs einsehen (nur Admin) |

View file

@ -1,383 +0,0 @@
# Payload API Konfiguration für externe Frontend-Zugriffe
## Kontext
Du arbeitest im Verzeichnis `/home/payload/payload-cms` auf dem Server sv-payload (10.10.181.100).
Das Frontend wird auf einem separaten Development-Server entwickelt:
- IP: 10.10.180.153
- Domain: dev.zh3.de
- Projekt: frontend-porwoll
Payload CMS muss so konfiguriert werden, dass externe Frontends auf die API zugreifen können.
## Aufgabe
1. CORS konfigurieren für Frontend-Zugriff
2. API-Zugriff ohne Authentifizierung für öffentliche Inhalte ermöglichen
3. Optional: GraphQL aktivieren
4. API Key für geschützte Operationen erstellen
## Schritt 1: CORS Konfiguration
Aktualisiere `src/payload.config.ts`:
```typescript
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import path from 'path'
import { fileURLToPath } from 'url'
// Collections
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Tenants } from './collections/Tenants'
import { Pages } from './collections/Pages'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { SocialLinks } from './collections/SocialLinks'
// Globals
import { SiteSettings } from './globals/SiteSettings'
import { Navigation } from './globals/Navigation'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
},
// CORS Konfiguration für externe Frontends
cors: [
'http://localhost:3000',
'http://localhost:3001',
'http://10.10.180.153:3000',
'http://10.10.180.153:3001',
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
],
// CSRF Protection - gleiche Origins
csrf: [
'http://localhost:3000',
'http://localhost:3001',
'http://10.10.180.153:3000',
'http://10.10.180.153:3001',
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
],
collections: [Users, Media, Tenants, Pages, Posts, Categories, SocialLinks],
globals: [SiteSettings, Navigation],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
plugins: [
multiTenantPlugin({
tenantsSlug: 'tenants',
collections: {
media: {},
pages: {},
posts: {},
categories: {},
'social-links': {},
},
debug: true,
}),
],
})
```
## Schritt 2: Öffentlichen API-Zugriff konfigurieren
Für öffentliche Inhalte (Pages, Posts) muss der `read`-Zugriff ohne Auth erlaubt werden.
### Pages Collection aktualisieren (`src/collections/Pages.ts`)
Füge Access Control hinzu:
```typescript
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'status', 'updatedAt'],
},
// Öffentlicher Lesezugriff für veröffentlichte Seiten
access: {
read: ({ req }) => {
// Eingeloggte User sehen alles
if (req.user) return true
// Öffentlich: nur veröffentlichte Seiten
return {
status: {
equals: 'published',
},
}
},
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
// ... bestehende Felder
],
}
```
### Posts Collection aktualisieren (`src/collections/Posts.ts`)
```typescript
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'status', 'publishedAt'],
},
access: {
read: ({ req }) => {
if (req.user) return true
return {
status: {
equals: 'published',
},
}
},
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
// ... bestehende Felder
],
}
```
### Media Collection aktualisieren (`src/collections/Media.ts`)
```typescript
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
admin: {
useAsTitle: 'alt',
},
access: {
// Medien sind öffentlich lesbar
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
upload: {
staticDir: 'media',
mimeTypes: ['image/*', 'application/pdf'],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
}
```
### Categories Collection (`src/collections/Categories.ts`)
```typescript
access: {
read: () => true, // Kategorien sind öffentlich
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
```
### SocialLinks Collection (`src/collections/SocialLinks.ts`)
```typescript
access: {
read: () => true, // Social Links sind öffentlich
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
```
### Globals öffentlich machen
#### SiteSettings (`src/globals/SiteSettings.ts`)
```typescript
import type { GlobalConfig } from 'payload'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: {
read: () => true, // Öffentlich lesbar
update: ({ req }) => !!req.user,
},
fields: [
// ... bestehende Felder
],
}
```
#### Navigation (`src/globals/Navigation.ts`)
```typescript
import type { GlobalConfig } from 'payload'
export const Navigation: GlobalConfig = {
slug: 'navigation',
access: {
read: () => true, // Öffentlich lesbar
update: ({ req }) => !!req.user,
},
fields: [
// ... bestehende Felder
],
}
```
## Schritt 3: GraphQL aktivieren (Optional)
Falls GraphQL gewünscht ist, installiere das Plugin:
```bash
pnpm add @payloadcms/graphql
```
Dann in `payload.config.ts`:
```typescript
import { buildConfig } from 'payload'
import { graphqlPlugin } from '@payloadcms/graphql'
export default buildConfig({
// ... andere Config
plugins: [
graphqlPlugin({}),
multiTenantPlugin({
// ...
}),
],
})
```
GraphQL Endpoint: `https://pl.c2sgmbh.de/api/graphql`
## Schritt 4: API Key für geschützte Operationen (Optional)
Für Operationen wie Kontaktformular-Submissions kann ein API Key erstellt werden.
### Umgebungsvariable hinzufügen
```bash
# In .env hinzufügen
PAYLOAD_API_KEY=dein-sicherer-api-key-hier-generieren
```
Generiere einen sicheren Key:
```bash
openssl rand -hex 32
```
### API Key Middleware (falls benötigt)
Für spezielle Endpoints kann der API Key geprüft werden. Für die meisten Fälle reicht jedoch die Access Control.
## Schritt 5: Media URL Konfiguration
Stelle sicher, dass Media-URLs korrekt sind:
```typescript
// In payload.config.ts
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de',
// ... rest
})
```
Die `.env` sollte enthalten:
```env
PAYLOAD_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
```
## Schritt 6: Build und Neustart
```bash
cd /home/payload/payload-cms
pnpm build
pm2 restart payload
```
## API Endpoints nach Konfiguration
| Endpoint | Methode | Beschreibung |
|----------|---------|--------------|
| `/api/pages` | GET | Alle veröffentlichten Seiten |
| `/api/pages?where[slug][equals]=home` | GET | Seite nach Slug |
| `/api/posts` | GET | Alle veröffentlichten Posts |
| `/api/posts?limit=10&page=1` | GET | Posts paginiert |
| `/api/categories` | GET | Alle Kategorien |
| `/api/media` | GET | Alle Medien |
| `/api/globals/site-settings` | GET | Site Settings |
| `/api/globals/navigation` | GET | Navigation |
## Test der API
Nach dem Neustart testen:
```bash
# Von sv-dev aus
curl https://pl.c2sgmbh.de/api/pages
curl https://pl.c2sgmbh.de/api/globals/site-settings
curl https://pl.c2sgmbh.de/api/globals/navigation
# Oder lokal auf sv-payload
curl http://localhost:3000/api/pages
```
## Erfolgskriterien
1. ✅ API antwortet auf Anfragen von dev.zh3.de ohne CORS-Fehler
2. ✅ Öffentliche Endpoints liefern Daten ohne Auth
3. ✅ Admin-Panel funktioniert weiterhin unter /admin
4. ✅ Media-URLs sind vollständig (mit Domain)
## Sicherheitshinweise
- Nur `read`-Zugriff ist öffentlich
- `create`, `update`, `delete` erfordern Authentifizierung
- Unveröffentlichte Inhalte sind nicht öffentlich sichtbar
- Admin-Panel bleibt geschützt

View file

@ -1,182 +0,0 @@
# Phase 1: Payload CMS Collections für porwoll.de
## Kontext
Du arbeitest im Verzeichnis `/home/payload/payload-cms`. Dies ist ein Payload CMS 3.x Projekt mit Multi-Tenant-Support. Der Tenant "porwoll" (ID: 1) existiert bereits.
Lies zuerst die CLAUDE.md für Projektkontext.
## Aufgabe
Erstelle die Collections und Globals für die Website porwoll.de. Die Website ist eine persönliche/berufliche Präsenz mit Blog.
## Zu erstellende Dateien
### 1. Collection: Pages (`src/collections/Pages.ts`)
Für statische Seiten wie Startseite, Mensch, Leben, etc.
```typescript
Felder:
- title: text, required
- slug: text, required, unique
- hero: group
- image: upload (Media)
- headline: text
- subline: textarea
- content: richText (Lexical)
- seo: group
- metaTitle: text
- metaDescription: textarea
- ogImage: upload (Media)
- status: select (draft, published), default: draft
- publishedAt: date
Admin:
- useAsTitle: 'title'
- defaultColumns: ['title', 'slug', 'status', 'updatedAt']
```
### 2. Collection: Posts (`src/collections/Posts.ts`)
Für Blog-Artikel.
```typescript
Felder:
- title: text, required
- slug: text, required, unique
- excerpt: textarea, maxLength 300
- content: richText (Lexical)
- featuredImage: upload (Media)
- category: relationship → Categories
- author: relationship → Users
- publishedAt: date
- status: select (draft, published), default: draft
Admin:
- useAsTitle: 'title'
- defaultColumns: ['title', 'category', 'status', 'publishedAt']
```
### 3. Collection: Categories (`src/collections/Categories.ts`)
Blog-Kategorien.
```typescript
Felder:
- name: text, required
- slug: text, required, unique
- description: textarea
Admin:
- useAsTitle: 'name'
```
### 4. Collection: SocialLinks (`src/collections/SocialLinks.ts`)
Social Media Verlinkungen.
```typescript
Felder:
- platform: select (facebook, x, instagram, youtube, linkedin, xing), required
- url: text, required
- isActive: checkbox, default: true
Admin:
- useAsTitle: 'platform'
```
### 5. Global: SiteSettings (`src/globals/SiteSettings.ts`)
Globale Website-Einstellungen.
```typescript
Felder:
- siteName: text, default: 'porwoll.de'
- siteTagline: text
- logo: upload (Media)
- favicon: upload (Media)
- contact: group
- email: email
- phone: text
- address: textarea
- footer: group
- copyrightText: text
- showSocialLinks: checkbox, default: true
- seo: group
- defaultMetaTitle: text
- defaultMetaDescription: textarea
- defaultOgImage: upload (Media)
```
### 6. Global: Navigation (`src/globals/Navigation.ts`)
Hauptnavigation der Website.
```typescript
Felder:
- mainMenu: array
- label: text, required
- type: select (page, custom, submenu)
- page: relationship → Pages (wenn type = page)
- url: text (wenn type = custom)
- openInNewTab: checkbox
- submenu: array (wenn type = submenu)
- label: text
- page: relationship → Pages
- url: text
- footerMenu: array
- label: text
- page: relationship → Pages
- url: text
```
## Umsetzungsschritte
1. Erstelle den Ordner `src/globals/` falls nicht vorhanden
2. Erstelle alle Collection-Dateien in `src/collections/`
3. Erstelle alle Global-Dateien in `src/globals/`
4. Aktualisiere `src/payload.config.ts`:
- Importiere alle neuen Collections
- Füge sie zum `collections` Array hinzu
- Importiere alle Globals
- Füge `globals: [SiteSettings, Navigation]` hinzu
5. Führe aus: `pnpm payload generate:types`
6. Führe aus: `pnpm payload migrate:create`
7. Führe aus: `pnpm payload migrate`
8. Führe aus: `pnpm build`
9. Starte neu: `pm2 restart payload`
## Wichtige Hinweise
- Alle Collections müssen Multi-Tenant-fähig sein (werden automatisch durch das Plugin gefiltert)
- Verwende den Lexical Editor für Rich Text: `import { lexicalEditor } from '@payloadcms/richtext-lexical'`
- Prüfe nach jedem Schritt auf TypeScript-Fehler
- Die Media Collection existiert bereits - nutze sie für alle Uploads
- Halte dich an Payload 3.x Syntax (nicht 2.x)
## Erfolgskriterien
Nach Abschluss sollten im Admin Panel unter https://pl.c2sgmbh.de/admin folgende Einträge sichtbar sein:
Collections:
- Users (existiert)
- Media (existiert)
- Tenants (existiert)
- Pages (neu)
- Posts (neu)
- Categories (neu)
- SocialLinks (neu)
Globals:
- Site Settings (neu)
- Navigation (neu)
## Bei Fehlern
- Lies die Fehlermeldung genau
- Prüfe die Payload 3.x Dokumentation
- Stelle sicher, dass alle Imports korrekt sind
- Prüfe TypeScript-Kompatibilität mit `pnpm tsc --noEmit`

View file

@ -1,755 +0,0 @@
# Phase 4: Content-Migration für porwoll.de
## Kontext
Das Payload CMS läuft unter https://pl.c2sgmbh.de/admin und das Frontend unter http://dev.zh3.de:3001.
Diese Phase befüllt Payload mit allen Inhalten der aktuellen porwoll.de WordPress-Seite.
## Übersicht der zu migrierenden Inhalte
| Bereich | Anzahl | Status |
|---------|--------|--------|
| Site Settings | 1 Global | ⏳ |
| Navigation | 1 Global | ⏳ |
| Social Links | 4 Einträge | ⏳ |
| Categories | 2 Kategorien | ⏳ |
| Pages | 10 Seiten | ⏳ |
| Media | ~10 Bilder | ⏳ |
---
## Teil 1: Site Settings konfigurieren
**URL:** https://pl.c2sgmbh.de/admin/globals/site-settings
### Einzutragende Werte
```yaml
Site Name: porwoll.de
Site Tagline: Die Webseite von Martin Porwoll
Contact:
Email: info@porwoll.de
Phone: 0800 80 44 100
Address: |
Hans-Böckler-Str. 19
46236 Bottrop
Footer:
Copyright Text: Martin Porwoll
Show Social Links: ✓ (aktiviert)
SEO:
Default Meta Title: porwoll.de | Die Webseite von Martin Porwoll
Default Meta Description: Martin Porwoll - Whistleblower, Unternehmer, Mensch. Engagiert für Patientenwohl und Transparenz im Gesundheitswesen.
```
### Logo & Favicon
1. Gehe zu **Media** → **Create New**
2. Lade hoch:
- Logo (falls vorhanden, sonst überspringen)
- Favicon: https://porwoll.de/wp-content/uploads/2024/05/cropped-Favicon-270x270.jpg
3. Gehe zurück zu **Site Settings**
4. Wähle die hochgeladenen Medien aus
---
## Teil 2: Social Links anlegen
**URL:** https://pl.c2sgmbh.de/admin/collections/social-links
### Einträge erstellen
| Platform | URL | Active |
|----------|-----|--------|
| facebook | https://www.facebook.com/martinporwoll | ✓ |
| x | https://x.com/martinporwoll | ✓ |
| instagram | https://www.instagram.com/martinporwoll | ✓ |
| youtube | https://www.youtube.com/@martinporwoll | ✓ |
| linkedin | https://www.linkedin.com/in/martinporwoll | ✓ |
**Hinweis:** Die exakten URLs müssen ggf. angepasst werden. Prüfe die aktuellen Social-Media-Profile.
---
## Teil 3: Categories anlegen
**URL:** https://pl.c2sgmbh.de/admin/collections/categories
### Einträge erstellen
| Name | Slug | Description |
|------|------|-------------|
| Whistleblowing | whistleblowing | Artikel zum Thema Whistleblowing und Zytoskandal |
| Unternehmer | unternehmer | Artikel über unternehmerische Aktivitäten |
---
## Teil 4: Media hochladen
**URL:** https://pl.c2sgmbh.de/admin/collections/media
### Bilder von WordPress herunterladen
Die folgenden Bilder müssen von der aktuellen WordPress-Seite heruntergeladen und in Payload hochgeladen werden:
| Dateiname | Quelle | Verwendung |
|-----------|--------|------------|
| martin-porwoll-frontal.webp | https://porwoll.de/wp-content/uploads/2024/05/martin-porwoll-frontal-1-1168x768-1.webp | Hero, Mensch-Seite |
| martin-porwoll-portrait.jpeg | https://porwoll.de/wp-content/uploads/2024/05/martin-porwoll-frontal-1-1168x768-1.jpeg | Mensch-Seite |
| gunshin-logo.webp | https://porwoll.de/wp-content/uploads/2024/05/gunshin-logo-1168x487-1.webp | Gunshin-Seite |
| adobestock-vision.webp | https://porwoll.de/wp-content/uploads/2024/05/adobestock-432768272kopie-2576x2050-1-scaled.webp | Gunshin-Seite |
| adobestock-erfolge.webp | https://porwoll.de/wp-content/uploads/2024/05/adobestock-585344607-kopie-2576x1717-1-scaled.webp | Gunshin-Seite |
| favicon.jpg | https://porwoll.de/wp-content/uploads/2024/05/cropped-Favicon-270x270.jpg | Site Settings |
### Upload-Prozess
1. Bilder lokal herunterladen (oder Originale verwenden falls verfügbar)
2. In Payload unter **Media****Create New** hochladen
3. **Alt-Text** für jedes Bild eintragen (wichtig für SEO/Accessibility)
### Alt-Texte
| Bild | Alt-Text |
|------|----------|
| martin-porwoll-frontal | Martin Porwoll - Portrait |
| gunshin-logo | gunshin Holding UG Logo |
| adobestock-vision | Abstrakte Darstellung von Vision und Innovation |
| adobestock-erfolge | Team-Erfolg und Zusammenarbeit |
---
## Teil 5: Navigation anlegen
**URL:** https://pl.c2sgmbh.de/admin/globals/navigation
### Main Menu Struktur
```yaml
Main Menu:
- Label: Whistleblowing
Type: submenu
Submenu:
- Label: Zytoskandal
Type: page
Page: zytoskandal
- Label: Whistleblowing
Type: page
Page: whistleblowing
- Label: Unternehmer
Type: submenu
Submenu:
- Label: gunshin Holding UG
Type: page
Page: gunshin-holding
- Label: complex care solutions GmbH
Type: page
Page: complex-care-solutions
- Label: Mensch
Type: page
Page: mensch
- Label: Kontakt
Type: page
Page: kontakt
```
### Footer Menu
```yaml
Footer Menu:
- Label: Impressum
Type: page
Page: impressum
- Label: Datenschutzerklärung
Type: page
Page: datenschutz
```
**Hinweis:** Die Pages müssen zuerst erstellt werden (Teil 6), bevor sie in der Navigation verlinkt werden können. Erstelle die Navigation daher erst nach den Pages, oder nutze zunächst "custom" Links.
---
## Teil 6: Pages erstellen
**URL:** https://pl.c2sgmbh.de/admin/collections/pages
### Seiten-Übersicht
| Seite | Slug | Priorität | Blöcke |
|-------|------|-----------|--------|
| Startseite | home | 🔴 Hoch | Hero, Text, CardGrid, Quote, CTA |
| Mensch | mensch | 🔴 Hoch | Hero, Text, ImageText, Timeline |
| Kontakt | kontakt | 🔴 Hoch | Text, ContactForm |
| Whistleblowing | whistleblowing | 🟡 Mittel | Hero, Text |
| Zytoskandal | zytoskandal | 🟡 Mittel | Hero, Text, Timeline |
| gunshin Holding | gunshin-holding | 🟡 Mittel | Hero, Text, CardGrid, ImageText |
| complex care solutions | complex-care-solutions | 🟡 Mittel | Hero, Text, ImageText |
| Leben | leben | 🟡 Mittel | Hero, Text |
| Impressum | impressum | 🟢 Niedrig | Text |
| Datenschutz | datenschutz | 🟢 Niedrig | Text |
---
### Seite 1: Startseite (home)
```yaml
Title: Startseite
Slug: home
Status: Published
Hero:
Image: (leer lassen, wird durch HeroBlock im Layout definiert)
Headline: (leer)
Subline: (leer)
Layout:
- Block: HeroBlock
Background Image: martin-porwoll-frontal
Headline: „Angst ist eine Reaktion, Mut eine Entscheidung"
Subline: Whistleblower | Unternehmer | Mensch
Alignment: center
Overlay: ✓
CTA:
Text: Mehr erfahren
Link: /mensch
Style: primary
- Block: TextBlock
Width: medium
Content: |
## Lebensaufgabe und Vision
Das Patientenwohl wieder in den Mittelpunkt aller Bemühungen im Gesundheitswesen zu rücken, ist die zentrale Lebensaufgabe von Martin Porwoll.
Er kämpft leidenschaftlich gegen Übertherapie und Fehlversorgung sowie Missbrauch im Gesundheitswesen und setzt sich für Transparenz, Gerechtigkeit und Integrität ein.
- Block: CardGridBlock
Headline: Unternehmer
Columns: 2
Cards:
- Title: gunshin Holding UG
Description: Lernen Sie die Gunshin Holding kennen, die Start-ups im Gesundheitssektor unterstützt und dazu beiträgt, innovative Unternehmen auf das Wohl der Patienten auszurichten.
Link: /gunshin-holding
Link Text: mehr
Image: gunshin-logo
- Title: complex care solutions GmbH
Description: Entdecken Sie das Unternehmen, das Martin Porwoll gegründet hat, um das Wohl der Patienten in den Mittelpunkt zu stellen und Übertherapie und Fehlversorgung zu bekämpfen.
Link: /complex-care-solutions
Link Text: mehr
- Block: QuoteBlock
Quote: Sein Motto „Angst ist eine Reaktion, Mut eine Entscheidung" spiegelt seine Entschlossenheit wider, mutig für das Wohl der Patienten einzutreten.
Style: highlighted
- Block: CTABlock
Headline: Kontakt aufnehmen
Description: Haben Sie Fragen oder möchten Sie mehr erfahren?
Background Color: dark
Buttons:
- Text: Kontakt
Link: /kontakt
Style: primary
SEO:
Meta Title: porwoll.de | Die Webseite von Martin Porwoll
Meta Description: Martin Porwoll - Whistleblower im Zytoskandal Bottrop, Unternehmer und Kämpfer für Patientenwohl. Transparenz, Gerechtigkeit und Integrität im Gesundheitswesen.
```
---
### Seite 2: Mensch
```yaml
Title: Mensch
Slug: mensch
Status: Published
Layout:
- Block: HeroBlock
Background Image: martin-porwoll-portrait
Headline: Martin Porwoll
Subline: Mensch
Alignment: center
Overlay: ✓
- Block: TextBlock
Width: medium
Content: |
Martin Porwoll ist ein engagierter Unternehmer im Gesundheitswesen und der entscheidende Whistleblower im Zytoskandal Bottrop. Seine Erfahrungen und sein unermüdlicher Einsatz für das Patientenwohl haben ihn zu einem inspirierenden Vorbild und einem wichtigen Akteur in der Branche gemacht.
- Block: ImageTextBlock
Image: martin-porwoll-portrait
Image Position: left
Headline: Persönlicher Hintergrund
Content: |
Martin Porwoll wurde in Bottrop, Deutschland, geboren und wuchs in einer Familie auf, die Wert auf soziale Verantwortung und Integrität legte. Diese Werte prägten seine Entscheidung, im Gesundheitswesen tätig zu werden und sich dafür einzusetzen, dass das Wohl der Patienten im Mittelpunkt steht.
CTA:
Text: Mehr zum Leben
Link: /leben
- Block: ImageTextBlock
Image Position: right
Headline: Whistleblower im Zytoskandal Bottrop
Content: |
Im Jahr 2016 machte Martin Porwoll als Whistleblower im Zytoskandal Bottrop Schlagzeilen. Er war maßgeblich daran beteiligt, einen groß angelegten Betrug in der Krebsmedikamentenversorgung aufzudecken, bei dem tausende Patienten betroffen waren. Martin Porwolls Mut und seine Entschlossenheit, das Richtige zu tun, führten zur Aufklärung des Skandals und zu weitreichenden Veränderungen im deutschen Gesundheitswesen.
CTA:
Text: Zum Zytoskandal
Link: /zytoskandal
- Block: CardGridBlock
Headline: Unternehmerische Tätigkeiten
Columns: 2
Cards:
- Title: complex care solutions GmbH
Description: Nach dem Zytoskandal gründete Martin Porwoll die complex care solutions GmbH, ein Unternehmen, das sich darauf konzentriert, Patientenwohl in den Vordergrund zu stellen.
Link: /complex-care-solutions
- Title: gunshin Holding UG
Description: Zusätzlich gründete Martin Porwoll die gunshin Holding, die Start-ups im Gesundheitswesen unterstützt.
Link: /gunshin-holding
SEO:
Meta Title: Martin Porwoll - Mensch | porwoll.de
Meta Description: Erfahren Sie mehr über Martin Porwoll - seinen persönlichen Hintergrund, seine Rolle als Whistleblower und seine unternehmerischen Aktivitäten im Gesundheitswesen.
```
---
### Seite 3: Kontakt
```yaml
Title: Kontakt
Slug: kontakt
Status: Published
Layout:
- Block: TextBlock
Width: medium
Content: |
## Lassen Sie uns reden!
Haben Sie Fragen, Anregungen oder möchten Sie mehr über die Arbeit von Martin Porwoll, den Zytoskandal Bottrop oder die complex care solutions GmbH erfahren?
Wir freuen uns von Ihnen zu hören! Zögern Sie nicht, uns zu kontaktieren unser Team steht Ihnen gerne zur Verfügung und beantwortet Ihre Fragen.
- Block: ContactFormBlock
Headline: Kontakt
Description: Schreiben Sie uns eine Nachricht
Recipient Email: info@porwoll.de
Show Phone: ✓
Show Address: ✓
Show Socials: ✓
SEO:
Meta Title: Kontakt | porwoll.de
Meta Description: Kontaktieren Sie Martin Porwoll - Telefon, E-Mail oder Kontaktformular. Wir freuen uns auf Ihre Nachricht.
```
---
### Seite 4: Whistleblowing
```yaml
Title: Whistleblowing
Slug: whistleblowing
Status: Published
Layout:
- Block: HeroBlock
Headline: Whistleblowing
Subline: Mut zur Wahrheit
Alignment: center
Overlay: ✓
- Block: TextBlock
Width: medium
Content: |
## Was ist Whistleblowing?
Whistleblowing bezeichnet das Aufdecken von Missständen, illegalen Praktiken oder Gefahren für die Öffentlichkeit durch Insider. Whistleblower setzen sich oft großen persönlichen Risiken aus, um die Wahrheit ans Licht zu bringen.
Martin Porwoll wurde 2016 zum Whistleblower, als er den größten Pharma-Skandal der deutschen Nachkriegsgeschichte aufdeckte.
- Block: CTABlock
Headline: Der Zytoskandal Bottrop
Description: Erfahren Sie mehr über den Fall, der das deutsche Gesundheitswesen erschütterte.
Buttons:
- Text: Zum Zytoskandal
Link: /zytoskandal
Style: primary
SEO:
Meta Title: Whistleblowing | porwoll.de
Meta Description: Whistleblowing - Mut zur Wahrheit. Erfahren Sie mehr über Martin Porwolls Rolle als Whistleblower im Zytoskandal Bottrop.
```
---
### Seite 5: Zytoskandal
```yaml
Title: Zytoskandal Bottrop
Slug: zytoskandal
Status: Published
Layout:
- Block: HeroBlock
Headline: Der Zytoskandal Bottrop
Subline: Der größte Pharma-Skandal der deutschen Nachkriegsgeschichte
Alignment: center
- Block: TextBlock
Width: medium
Content: |
## Was geschah?
Im Jahr 2016 wurde aufgedeckt, dass ein Apotheker in Bottrop über Jahre hinweg Krebsmedikamente gestreckt oder durch Kochsalzlösung ersetzt hatte. Tausende Krebspatienten erhielten unwirksame Behandlungen.
Martin Porwoll, damals kaufmännischer Leiter der Apotheke, war maßgeblich an der Aufdeckung dieses Skandals beteiligt.
- Block: TimelineBlock
Headline: Chronologie der Ereignisse
Events:
- Year: "2016"
Title: Aufdeckung
Description: Martin Porwoll bemerkt Unregelmäßigkeiten und beginnt zu recherchieren
- Year: "2016"
Title: Anzeige
Description: Der Fall wird den Behörden gemeldet
- Year: "2017"
Title: Verhaftung
Description: Der verantwortliche Apotheker wird verhaftet
- Year: "2018"
Title: Verurteilung
Description: Verurteilung zu 12 Jahren Haft
- Block: TextBlock
Width: medium
Content: |
## Die Folgen
Der Zytoskandal führte zu weitreichenden Änderungen im deutschen Gesundheitswesen:
- Verschärfte Kontrollen bei der Herstellung von Krebsmedikamenten
- Neue gesetzliche Regelungen zum Schutz von Whistleblowern
- Erhöhtes Bewusstsein für Patientensicherheit
SEO:
Meta Title: Zytoskandal Bottrop | porwoll.de
Meta Description: Der Zytoskandal Bottrop - wie Martin Porwoll den größten Pharma-Skandal der deutschen Nachkriegsgeschichte aufdeckte.
```
---
### Seite 6: gunshin Holding
```yaml
Title: gunshin Holding UG
Slug: gunshin-holding
Status: Published
Layout:
- Block: HeroBlock
Background Image: gunshin-logo
Headline: gunshin Holding UG
Alignment: center
Overlay: ✓
- Block: TextBlock
Width: medium
Content: |
Die gunshin Holding UG, gegründet von Martin Porwoll, ist eine Beteiligungsgesellschaft, die sich auf die Unterstützung und Förderung von Start-ups und jungen Unternehmen im Gesundheitswesen konzentriert.
Die Holding hat es sich zur Aufgabe gemacht, innovative Ideen und Lösungen zu fördern, die das Wohl des Patienten in den Mittelpunkt stellen und einen positiven Einfluss auf die Branche haben.
- Block: ImageTextBlock
Image: adobestock-vision
Image Position: right
Headline: Vision und Mission
Content: |
Die Vision der gunshin Holding UG ist es, durch die Förderung von Start-ups und innovativen Lösungen den Gesundheitssektor nachhaltig zu verändern und damit das Patientenwohl zu stärken.
Die Werte des Unternehmens basieren auf Transparenz, Integrität und Kooperation, um gemeinsam mit den geförderten Start-ups erfolgreich zu wachsen.
- Block: CardGridBlock
Headline: Unsere Leistungen
Columns: 3
Cards:
- Title: Strategische Beratung
Description: Die gunshin Holding UG bietet den Start-Ups wertvolle strategische Beratung und unterstützt sie bei der Entwicklung von Geschäftsmodellen, Markteintrittsstrategien und Wachstumsplänen.
- Title: Netzwerk und Partnerschaften
Description: Die Holding ermöglicht den Start-Ups den Zugang zu einem breiten Netzwerk von Experten, Partnern und potenziellen Kunden, um die Erfolgschancen zu erhöhen.
- Title: Ressourcen und Infrastruktur
Description: Die gunshin Holding UG stellt den Start-Ups Ressourcen wie Büroräume, technische Infrastruktur und administrative Unterstützung zur Verfügung.
- Block: ImageTextBlock
Image: adobestock-erfolge
Image Position: left
Headline: Erfolge und Referenzen
Content: |
Die gunshin Holding UG hat bereits mehrere Start-ups erfolgreich unterstützt und kann auf eine Reihe von Erfolgsgeschichten im Gesundheitswesen verweisen.
Diese Erfolge zeigen, dass die Vision von Martin Porwoll, durch die Förderung innovativer Start-Ups das Wohl der Patienten in den Mittelpunkt zu stellen, Früchte trägt.
CTA:
Text: Zur gunshin.de
Link: https://gunshin.de
SEO:
Meta Title: gunshin Holding UG | porwoll.de
Meta Description: Die gunshin Holding UG unterstützt innovative Start-ups im Gesundheitswesen. Strategische Beratung, Netzwerk und Ressourcen für Unternehmen mit Fokus auf Patientenwohl.
```
---
### Seite 7: complex care solutions
```yaml
Title: complex care solutions GmbH
Slug: complex-care-solutions
Status: Published
Layout:
- Block: HeroBlock
Headline: complex care solutions GmbH
Subline: Patientenwohl im Mittelpunkt
Alignment: center
- Block: TextBlock
Width: medium
Content: |
Die complex care solutions GmbH wurde von Martin Porwoll gegründet, um das Wohl der Patienten in den Mittelpunkt zu stellen und Übertherapie sowie Fehlversorgung aktiv zu bekämpfen.
Das Unternehmen arbeitet eng mit medizinischen Einrichtungen, Krankenkassen und anderen Akteuren im Gesundheitswesen zusammen, um bessere und sicherere Versorgungslösungen für Patienten zu entwickeln.
- Block: CTABlock
Headline: Mehr erfahren
Description: Besuchen Sie die Webseite der complex care solutions GmbH
Buttons:
- Text: Zur complexcaresolutions.de
Link: https://complexcaresolutions.de
Style: primary
SEO:
Meta Title: complex care solutions GmbH | porwoll.de
Meta Description: Die complex care solutions GmbH - gegründet von Martin Porwoll für Patientenwohl und gegen Übertherapie im Gesundheitswesen.
```
---
### Seite 8: Leben
```yaml
Title: Leben
Slug: leben
Status: Published
Layout:
- Block: HeroBlock
Headline: Leben
Subline: „Angst ist eine Reaktion, Mut eine Entscheidung"
Alignment: center
- Block: TextBlock
Width: medium
Content: |
Diese Seite wird noch mit Inhalten gefüllt.
Hier wird Martin Porwolls persönlicher Werdegang und seine Lebensgeschichte präsentiert.
SEO:
Meta Title: Leben | porwoll.de
Meta Description: Das Leben von Martin Porwoll - persönlicher Werdegang und Geschichte.
```
---
### Seite 9: Impressum
```yaml
Title: Impressum
Slug: impressum
Status: Published
Layout:
- Block: TextBlock
Width: narrow
Content: |
## Impressum
**Angaben gemäß § 5 TMG**
Martin Porwoll
Hans-Böckler-Str. 19
46236 Bottrop
**Kontakt**
Telefon: 0800 80 44 100
E-Mail: info@porwoll.de
**Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV**
Martin Porwoll
Hans-Böckler-Str. 19
46236 Bottrop
**Haftung für Inhalte**
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht unter Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.
SEO:
Meta Title: Impressum | porwoll.de
Meta Description: Impressum der Webseite porwoll.de
```
---
### Seite 10: Datenschutz
```yaml
Title: Datenschutzerklärung
Slug: datenschutz
Status: Published
Layout:
- Block: TextBlock
Width: narrow
Content: |
## Datenschutzerklärung
**1. Datenschutz auf einen Blick**
**Allgemeine Hinweise**
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
**Datenerfassung auf dieser Website**
*Wer ist verantwortlich für die Datenerfassung auf dieser Website?*
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
*Wie erfassen wir Ihre Daten?*
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z.B. Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs).
**2. Hosting**
Diese Website wird auf eigenen Servern in Deutschland gehostet.
**3. Kontaktformular**
Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.
(Diese Datenschutzerklärung ist ein Platzhalter und muss durch eine vollständige, rechtskonforme Version ersetzt werden.)
SEO:
Meta Title: Datenschutzerklärung | porwoll.de
Meta Description: Datenschutzerklärung der Webseite porwoll.de
```
---
## Teil 7: Navigation verknüpfen
Nachdem alle Pages erstellt sind:
1. Gehe zu **https://pl.c2sgmbh.de/admin/globals/navigation**
2. Bearbeite **Main Menu** und **Footer Menu**
3. Ändere die Links von "custom" auf "page" und wähle die entsprechenden Seiten aus
---
## Teil 8: Verifizierung
### Checkliste
- [ ] Site Settings vollständig ausgefüllt
- [ ] Alle Social Links angelegt
- [ ] Beide Categories angelegt
- [ ] Alle Medien hochgeladen mit Alt-Texten
- [ ] Alle 10 Pages erstellt und auf "Published" gesetzt
- [ ] Navigation Main Menu konfiguriert
- [ ] Navigation Footer Menu konfiguriert
### Frontend testen
```bash
# Auf sv-dev
cd /home/developer/workspace/frontend-porwoll
npm run dev
```
Dann im Browser http://dev.zh3.de:3001 öffnen und prüfen:
- [ ] Startseite lädt mit Hero und Content
- [ ] Navigation funktioniert
- [ ] Unterseiten sind erreichbar
- [ ] Bilder werden geladen
- [ ] Footer zeigt Kontaktdaten
- [ ] Mobile Ansicht funktioniert
### API-Endpoints prüfen
```bash
curl https://pl.c2sgmbh.de/api/pages | jq '.docs | length'
# Sollte 10 zurückgeben
curl https://pl.c2sgmbh.de/api/globals/navigation | jq '.mainMenu | length'
# Sollte 4 zurückgeben (Hauptmenü-Einträge)
```
---
## Hinweise
### Rich Text Editor (Lexical)
Beim Eingeben von Content in TextBlocks nutzt Payload den Lexical Editor. Formatierungen:
- **Fett:** Text markieren → Bold klicken
- **Überschriften:** Text markieren → Heading-Dropdown
- **Links:** Text markieren → Link-Icon
- **Listen:** Bullet-Icon oder Nummerierung
### Bilder in Blöcken
Bei Blöcken mit Bild-Feldern:
1. Klicke auf "Select Media" oder "Upload"
2. Wähle ein bereits hochgeladenes Bild oder lade ein neues hoch
3. Bild wird automatisch verknüpft
### Reihenfolge der Blöcke
Blöcke können per Drag & Drop umsortiert werden. Die Reihenfolge im Admin entspricht der Reihenfolge auf der Website.
---
## Geschätzte Zeit
| Aufgabe | Dauer |
|---------|-------|
| Site Settings | 5 Min |
| Social Links | 5 Min |
| Categories | 2 Min |
| Media Upload | 10 Min |
| Pages erstellen | 45-60 Min |
| Navigation | 10 Min |
| Verifizierung | 10 Min |
| **Gesamt** | **~90 Min** |

View file

@ -1,457 +0,0 @@
# PROMPT: Datenschutzerklärung Integration - Payload Backend
## Kontext
Du arbeitest auf dem Server **sv-payload** (10.10.181.100) im Verzeichnis `/home/payload/payload-cms`.
Die Datenschutzerklärungen werden extern vom Datenschutzbeauftragten über **Alfright** gepflegt und sollen per iframe eingebunden werden. Die Konfiguration (Tenant-Key, Styling) wird pro Mandant in Payload CMS verwaltet.
## Aufgabe
Erweitere das System um eine **PrivacyPolicySettings Collection** für die Verwaltung der externen Datenschutzerklärung pro Tenant.
---
## Schritt 1: Collection erstellen
Erstelle `src/collections/PrivacyPolicySettings.ts`:
```typescript
// src/collections/PrivacyPolicySettings.ts
import type { CollectionConfig } from 'payload'
import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'
/**
* PrivacyPolicySettings Collection
*
* Konfiguration für externe Datenschutzerklärung (Alfright) pro Tenant.
* Öffentlich lesbar (für Frontend), aber tenant-isoliert.
*/
export const PrivacyPolicySettings: CollectionConfig = {
slug: 'privacy-policy-settings',
admin: {
useAsTitle: 'title',
group: 'Consent Management',
description: 'Externe Datenschutzerklärung Konfiguration (Alfright)',
},
access: {
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
unique: true,
admin: {
description: 'Jeder Tenant kann nur eine Konfiguration haben',
},
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Datenschutzerklärung',
admin: {
description: 'Interner Titel zur Identifikation',
},
},
{
name: 'provider',
type: 'select',
required: true,
defaultValue: 'alfright',
options: [
{ label: 'Alfright (extern via iframe)', value: 'alfright' },
{ label: 'Eigener Text (nicht implementiert)', value: 'internal' },
],
admin: {
description: 'Quelle der Datenschutzerklärung',
},
},
// Alfright Konfiguration
{
name: 'alfright',
type: 'group',
label: 'Alfright Konfiguration',
admin: {
condition: (data) => data?.provider === 'alfright',
description: 'Einstellungen für die Alfright Integration',
},
fields: [
{
name: 'tenantId',
type: 'text',
required: true,
defaultValue: 'alfright_schutzteam',
admin: {
description: 'Alfright Tenant-ID (aus dem iframe-Code)',
},
},
{
name: 'apiKey',
type: 'text',
required: true,
admin: {
description: 'Alfright API-Key / Dokument-ID (aus dem iframe-Code, z.B. "9f315103c43245bcb0806dd56c2be757")',
},
},
{
name: 'language',
type: 'select',
required: true,
defaultValue: 'de-de',
options: [
{ label: 'Deutsch (Deutschland)', value: 'de-de' },
{ label: 'Deutsch (Österreich)', value: 'de-at' },
{ label: 'Deutsch (Schweiz)', value: 'de-ch' },
{ label: 'Englisch (UK)', value: 'en-gb' },
{ label: 'Englisch (US)', value: 'en-us' },
],
admin: {
description: 'Sprache der Datenschutzerklärung',
},
},
{
name: 'iframeHeight',
type: 'number',
required: true,
defaultValue: 4000,
min: 500,
max: 10000,
admin: {
description: 'Höhe des iframes in Pixeln (empfohlen: 3000-5000)',
},
},
],
},
// Styling (passend zum Website-Theme)
{
name: 'styling',
type: 'group',
label: 'Styling',
admin: {
condition: (data) => data?.provider === 'alfright',
description: 'Farben und Schriften an das Website-Design anpassen',
},
fields: [
{
name: 'headerColor',
type: 'text',
required: true,
defaultValue: '#ca8a04',
admin: {
description: 'Farbe der Überschriften (Hex-Code, z.B. #ca8a04 für Gold)',
},
},
{
name: 'headerFont',
type: 'text',
required: true,
defaultValue: 'Inter, sans-serif',
admin: {
description: 'Schriftart der Überschriften',
},
},
{
name: 'headerSize',
type: 'text',
required: true,
defaultValue: '24px',
admin: {
description: 'Schriftgröße der Hauptüberschriften',
},
},
{
name: 'subheaderSize',
type: 'text',
required: true,
defaultValue: '18px',
admin: {
description: 'Schriftgröße der Unterüberschriften',
},
},
{
name: 'fontColor',
type: 'text',
required: true,
defaultValue: '#f3f4f6',
admin: {
description: 'Textfarbe (Hex-Code, z.B. #f3f4f6 für hellen Text)',
},
},
{
name: 'textFont',
type: 'text',
required: true,
defaultValue: 'Inter, sans-serif',
admin: {
description: 'Schriftart für Fließtext',
},
},
{
name: 'textSize',
type: 'text',
required: true,
defaultValue: '16px',
admin: {
description: 'Schriftgröße für Fließtext',
},
},
{
name: 'linkColor',
type: 'text',
required: true,
defaultValue: '#ca8a04',
admin: {
description: 'Linkfarbe (Hex-Code)',
},
},
{
name: 'backgroundColor',
type: 'text',
required: true,
defaultValue: '#111827',
admin: {
description: 'Hintergrundfarbe (Hex-Code, z.B. #111827 für Dark Theme)',
},
},
],
},
// Cookie-Tabelle Option
{
name: 'showCookieTable',
type: 'checkbox',
defaultValue: true,
admin: {
description: 'Cookie-Tabelle aus CookieInventory unterhalb der Datenschutzerklärung anzeigen',
},
},
{
name: 'cookieTableTitle',
type: 'text',
defaultValue: 'Übersicht der verwendeten Cookies',
admin: {
condition: (data) => data?.showCookieTable,
description: 'Überschrift für die Cookie-Tabelle',
},
},
{
name: 'cookieTableDescription',
type: 'textarea',
defaultValue: 'Ergänzend zur Datenschutzerklärung finden Sie hier eine detaillierte Übersicht aller auf dieser Website eingesetzten Cookies. Sie können Ihre Cookie-Einstellungen jederzeit über den Link "Cookie-Einstellungen" im Footer anpassen.',
admin: {
condition: (data) => data?.showCookieTable,
description: 'Einleitungstext für die Cookie-Tabelle',
},
},
// SEO
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
defaultValue: 'Datenschutzerklärung',
admin: {
description: 'Meta-Titel für die Seite',
},
},
{
name: 'metaDescription',
type: 'textarea',
defaultValue: 'Informationen zum Datenschutz und zur Verarbeitung Ihrer personenbezogenen Daten.',
admin: {
description: 'Meta-Beschreibung für Suchmaschinen',
},
},
],
},
],
}
```
---
## Schritt 2: Collection in Payload Config registrieren
Aktualisiere `src/payload.config.ts`:
```typescript
// Import hinzufügen (bei den anderen Collection-Imports)
import { PrivacyPolicySettings } from './collections/PrivacyPolicySettings'
// In collections Array hinzufügen
collections: [
Users,
Media,
Tenants,
Pages,
Posts,
Categories,
SocialLinks,
CookieConfigurations,
CookieInventory,
ConsentLogs,
PrivacyPolicySettings, // NEU
],
// In multiTenantPlugin collections hinzufügen
plugins: [
multiTenantPlugin({
tenantsSlug: 'tenants',
collections: {
// ... bestehende Collections ...
'privacy-policy-settings': {}, // NEU
},
}),
],
```
---
## Schritt 3: Build und Migration
```bash
cd /home/payload/payload-cms
# TypeScript Types generieren
pnpm payload generate:types
# Migration erstellen
pnpm payload migrate:create
# Migration ausführen
pnpm payload migrate
# Build
pnpm build
# PM2 neu starten
pm2 restart payload
```
---
## Schritt 4: Initiale Daten anlegen
Im Admin Panel unter **Consent Management → Privacy Policy Settings → Create**:
### Für porwoll.de (Tenant 1):
| Feld | Wert |
|------|------|
| Tenant | porwoll.de |
| Title | Datenschutzerklärung porwoll.de |
| Provider | Alfright (extern via iframe) |
**Alfright Konfiguration:**
| Feld | Wert |
|------|------|
| Tenant ID | `alfright_schutzteam` |
| API Key | `9f315103c43245bcb0806dd56c2be757` |
| Language | Deutsch (Deutschland) |
| iframe Height | 4000 |
**Styling (Dark Theme):**
| Feld | Wert |
|------|------|
| Header Color | `#ca8a04` |
| Header Font | `Inter, sans-serif` |
| Header Size | `24px` |
| Subheader Size | `18px` |
| Font Color | `#f3f4f6` |
| Text Font | `Inter, sans-serif` |
| Text Size | `16px` |
| Link Color | `#ca8a04` |
| Background Color | `#111827` |
**Cookie-Tabelle:**
| Feld | Wert |
|------|------|
| Show Cookie Table | ✅ Aktiviert |
| Cookie Table Title | Übersicht der verwendeten Cookies |
**SEO:**
| Feld | Wert |
|------|------|
| Meta Title | Datenschutzerklärung \| porwoll.de |
| Meta Description | Informationen zum Datenschutz und zur Verarbeitung Ihrer personenbezogenen Daten auf porwoll.de |
---
## Schritt 5: API-Test
```bash
# Privacy Policy Settings abrufen (mit Host-Header)
curl -s -H "Host: porwoll.de" "http://localhost:3000/api/privacy-policy-settings" | jq
# Ohne Host-Header (sollte verweigert werden)
curl -s "http://localhost:3000/api/privacy-policy-settings" | jq
```
---
## Zusammenfassung
| Datei | Aktion |
|-------|--------|
| `src/collections/PrivacyPolicySettings.ts` | NEU erstellt |
| `src/payload.config.ts` | Collection registriert |
| `src/payload-types.ts` | Automatisch generiert |
## API-Endpoint
| Endpoint | Methode | Auth | Beschreibung |
|----------|---------|------|--------------|
| `/api/privacy-policy-settings` | GET | Public (tenant-scoped) | Datenschutz-Konfiguration |
## Datenmodell
```typescript
interface PrivacyPolicySettings {
id: number
tenant: Tenant
title: string
provider: 'alfright' | 'internal'
alfright: {
tenantId: string
apiKey: string
language: string
iframeHeight: number
}
styling: {
headerColor: string
headerFont: string
headerSize: string
subheaderSize: string
fontColor: string
textFont: string
textSize: string
linkColor: string
backgroundColor: string
}
showCookieTable: boolean
cookieTableTitle: string
cookieTableDescription: string
seo: {
metaTitle: string
metaDescription: string
}
}
```

File diff suppressed because it is too large Load diff

View file

@ -1,323 +0,0 @@
# Phase 2: Block-System für flexible Seiteninhalte
## Kontext
Du arbeitest im Verzeichnis `/home/payload/payload-cms`. Phase 1 ist abgeschlossen - die Collections Pages, Posts, Categories, SocialLinks sowie die Globals SiteSettings und Navigation existieren.
## Aufgabe
Erstelle ein Block-System, das flexible Seitengestaltung im Pages-Editor ermöglicht. Die Blöcke werden als `blocks` Field in der Pages Collection integriert.
## Design-Kontext
Die Website porwoll.de ist eine seriöse, professionelle Präsenz mit:
- Dunklem Theme
- Ernsthafter, vertrauenswürdiger Ausstrahlung
- Klarer Typografie
- Hochwertigen Bildern
## Zu erstellende Dateien
### 1. Block-Definitionen (`src/blocks/`)
Erstelle den Ordner `src/blocks/` und folgende Dateien:
#### `src/blocks/HeroBlock.ts`
Große Hero-Sektion mit Bild und Text.
```typescript
Felder:
- backgroundImage: upload (Media)
- headline: text, required
- subline: textarea
- alignment: select (left, center, right), default: center
- overlay: checkbox, default: true (dunkles Overlay über Bild)
- cta: group
- text: text
- link: text
- style: select (primary, secondary, outline)
```
#### `src/blocks/TextBlock.ts`
Einfacher Textblock mit Rich-Text.
```typescript
Felder:
- content: richText (Lexical), required
- width: select (narrow, medium, full), default: medium
```
#### `src/blocks/ImageTextBlock.ts`
Bild und Text nebeneinander.
```typescript
Felder:
- image: upload (Media), required
- imagePosition: select (left, right), default: left
- headline: text
- content: richText (Lexical)
- cta: group
- text: text
- link: text
```
#### `src/blocks/CardGridBlock.ts`
Raster aus Karten (für Unternehmen, Projekte, etc.).
```typescript
Felder:
- headline: text
- cards: array, minRows: 1, maxRows: 6
- image: upload (Media)
- title: text, required
- description: textarea
- link: text
- linkText: text, default: 'mehr'
- columns: select (2, 3, 4), default: 3
```
#### `src/blocks/QuoteBlock.ts`
Hervorgehobenes Zitat.
```typescript
Felder:
- quote: textarea, required
- author: text
- role: text
- image: upload (Media) (optional, für Autorenbild)
- style: select (simple, highlighted, with-image), default: simple
```
#### `src/blocks/CTABlock.ts`
Call-to-Action Sektion.
```typescript
Felder:
- headline: text, required
- description: textarea
- buttons: array, maxRows: 2
- text: text, required
- link: text, required
- style: select (primary, secondary, outline), default: primary
- backgroundColor: select (dark, light, accent), default: dark
```
#### `src/blocks/ContactFormBlock.ts`
Kontaktformular-Einbettung.
```typescript
Felder:
- headline: text, default: 'Kontakt'
- description: textarea
- recipientEmail: email, default: 'info@porwoll.de'
- showPhone: checkbox, default: true
- showAddress: checkbox, default: true
- showSocials: checkbox, default: true
```
#### `src/blocks/TimelineBlock.ts`
Zeitstrahl für Lebenslauf/Geschichte.
```typescript
Felder:
- headline: text
- events: array, minRows: 1
- year: text, required
- title: text, required
- description: textarea
- image: upload (Media)
```
#### `src/blocks/DividerBlock.ts`
Einfacher Trenner zwischen Sektionen.
```typescript
Felder:
- style: select (line, space, dots), default: space
- spacing: select (small, medium, large), default: medium
```
#### `src/blocks/VideoBlock.ts`
Video-Einbettung (YouTube/Vimeo).
```typescript
Felder:
- videoUrl: text, required (YouTube oder Vimeo URL)
- caption: text
- aspectRatio: select (16:9, 4:3, 1:1), default: 16:9
```
### 2. Block-Index (`src/blocks/index.ts`)
Exportiere alle Blöcke zentral:
```typescript
export { HeroBlock } from './HeroBlock'
export { TextBlock } from './TextBlock'
export { ImageTextBlock } from './ImageTextBlock'
export { CardGridBlock } from './CardGridBlock'
export { QuoteBlock } from './QuoteBlock'
export { CTABlock } from './CTABlock'
export { ContactFormBlock } from './ContactFormBlock'
export { TimelineBlock } from './TimelineBlock'
export { DividerBlock } from './DividerBlock'
export { VideoBlock } from './VideoBlock'
```
### 3. Pages Collection aktualisieren (`src/collections/Pages.ts`)
Ersetze das `content` Feld durch ein `layout` Blocks-Feld:
```typescript
{
name: 'layout',
type: 'blocks',
blocks: [
HeroBlock,
TextBlock,
ImageTextBlock,
CardGridBlock,
QuoteBlock,
CTABlock,
ContactFormBlock,
TimelineBlock,
DividerBlock,
VideoBlock,
],
}
```
Behalte das bestehende `hero` Feld für die Standard-Hero-Sektion, aber das `layout` Feld ermöglicht flexible Inhalte darunter.
### 4. Posts Collection aktualisieren (`src/collections/Posts.ts`)
Füge optional auch Blocks zum Posts-Content hinzu, oder behalte Rich-Text für einfachere Blog-Posts. Empfehlung: Behalte Rich-Text für Posts, da Blog-Artikel primär Text sind.
## Block-Struktur Template
Jeder Block sollte dieser Struktur folgen:
```typescript
import type { Block } from 'payload'
export const BlockName: Block = {
slug: 'block-name',
labels: {
singular: 'Block Name',
plural: 'Block Names',
},
fields: [
// Felder hier
],
}
```
## Umsetzungsschritte
1. Erstelle `src/blocks/` Verzeichnis
2. Erstelle alle Block-Dateien
3. Erstelle `src/blocks/index.ts`
4. Aktualisiere `src/collections/Pages.ts` mit dem Blocks-Feld
5. Generiere TypeScript-Types (siehe Prettier-Workaround unten)
6. Führe aus: `pnpm payload migrate:create`
7. Führe aus: `pnpm payload migrate`
8. Führe aus: `pnpm build`
9. Starte neu: `pm2 restart payload`
## Prettier-Workaround für generate:types
Es gibt ein bekanntes Problem: `pnpm payload generate:types` ignoriert die Projekt-Prettier-Konfiguration und kann zu Konflikten führen (GitHub PR #11124, Stand: offen).
### Lösung: Prettier-Konfiguration anlegen
**Schritt 1:** Erstelle eine kompatible `.prettierrc` im Projektroot:
```bash
cat > /home/payload/payload-cms/.prettierrc << 'EOF'
{
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"printWidth": 100
}
EOF
```
**Schritt 2:** Installiere Prettier falls nicht vorhanden:
```bash
pnpm add -D prettier
```
**Schritt 3:** Generiere Types und formatiere:
```bash
pnpm payload generate:types
pnpm prettier --write src/payload-types.ts
```
### Alternative bei Fehlern
Falls `generate:types` weiterhin fehlschlägt, Types manuell in `src/payload-types.ts` ergänzen:
1. Öffne die bestehende `src/payload-types.ts`
2. Füge die neuen Block-Interfaces hinzu (HeroBlock, TextBlock, etc.)
3. Aktualisiere das `Page` Interface mit dem `layout` Feld
Beispiel für manuelle Block-Types:
```typescript
export interface HeroBlock {
blockType: 'hero-block'
backgroundImage?: string | Media | null
headline: string
subline?: string | null
alignment?: 'left' | 'center' | 'right' | null
overlay?: boolean | null
cta?: {
text?: string | null
link?: string | null
style?: 'primary' | 'secondary' | 'outline' | null
}
id?: string | null
}
// ... weitere Block-Interfaces
```
## Wichtige Hinweise
- Nutze `type: 'blocks'` für das Layout-Feld
- Alle Blöcke müssen als `Block` Type aus 'payload' importiert werden
- Labels auf Deutsch setzen für bessere Admin-UX
- Bei Fehlern mit Prettier: Types manuell anpassen wie in Phase 1
## Erfolgskriterien
Nach Abschluss:
1. Im Admin unter Pages → [Seite bearbeiten] erscheint ein "Layout" Feld
2. Das Layout-Feld zeigt alle 10 Block-Typen zur Auswahl
3. Blöcke können hinzugefügt, sortiert und bearbeitet werden
4. Server läuft ohne Fehler
## Test
1. Gehe zu https://pl.c2sgmbh.de/admin
2. Erstelle eine neue Page
3. Scrolle zum "Layout" Bereich
4. Klicke "Add Block"
5. Alle 10 Block-Typen sollten verfügbar sein
6. Füge einen HeroBlock hinzu und speichere
7. Prüfe in der Datenbank: `SELECT * FROM pages;`

View file

@ -1,733 +0,0 @@
Kontext
Du arbeitest auf dem Server sv-payload im Verzeichnis /home/payload/payload-cms.
Ein Sicherheits-Audit hat kritische Schwachstellen identifiziert, die sofort behoben werden müssen.
Audit-Findings (KRITISCH)
#SchwachstelleDateiRisiko1PAYLOAD_SECRET Fallback auf leeren Stringpayload.config.tsToken-Fälschung2CONSENT_LOGGING_API_KEY undefined = BypassConsentLogs.tsUnautorisierter Schreibzugriff3IP_ANONYMIZATION_PEPPER hardcoded FallbackConsentLogs.tsRainbow-Table Angriff4GraphQL Playground in Productiongraphql-playground/route.tsSchema-Leak5Multi-Tenant Read Access ohne Domain-CheckCookieConfigurations.ts, CookieInventory.tsTenant-Daten-Leak
Aufgabe 1: Zentrale Environment-Validierung erstellen
Erstelle src/lib/envValidation.ts:
typescript// src/lib/envValidation.ts
/**
* Zentrale Validierung aller erforderlichen Environment-Variablen.
* Wird beim Server-Start aufgerufen und beendet den Prozess bei fehlenden Werten.
*/
interface RequiredEnvVars {
PAYLOAD_SECRET: string
DATABASE_URI: string
CONSENT_LOGGING_API_KEY: string
IP_ANONYMIZATION_PEPPER: string
}
const FORBIDDEN_VALUES = [
'',
'default-pepper-change-me',
'change-me',
'your-secret-here',
'xxx',
]
function validateEnvVar(name: string, value: string | undefined): string {
if (!value || value.trim() === '') {
throw new Error(
`FATAL: Environment variable ${name} is required but not set. ` +
`Server cannot start without this value.`
)
}
if (FORBIDDEN_VALUES.includes(value.trim().toLowerCase())) {
throw new Error(
`FATAL: Environment variable ${name} has an insecure default value. ` +
`Please set a secure random value.`
)
}
return value.trim()
}
/**
* Validiert alle erforderlichen Environment-Variablen.
* Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen.
*/
export function validateRequiredEnvVars(): RequiredEnvVars {
return {
PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),
CONSENT_LOGGING_API_KEY: validateEnvVar('CONSENT_LOGGING_API_KEY', process.env.CONSENT_LOGGING_API_KEY),
IP_ANONYMIZATION_PEPPER: validateEnvVar('IP_ANONYMIZATION_PEPPER', process.env.IP_ANONYMIZATION_PEPPER),
}
}
/**
* Bereits validierte Environment-Variablen.
* Wird beim Import ausgeführt (Fail-Fast Prinzip).
*/
export const env = validateRequiredEnvVars()
Aufgabe 2: Tenant-Access Utility erstellen
Erstelle src/lib/tenantAccess.ts:
typescript// src/lib/tenantAccess.ts
import type { Access, PayloadRequest } from 'payload'
/**
* Ermittelt die Tenant-ID aus dem Request-Host.
* Gleicht die Domain mit der tenants-Collection ab.
*/
export async function getTenantIdFromHost(req: PayloadRequest): Promise<number | null> {
try {
// Host-Header extrahieren (unterstützt verschiedene Formate)
const host =
req.headers?.host ||
(req.headers?.get && req.headers.get('host')) ||
null
if (!host || typeof host !== 'string') {
return null
}
// Domain normalisieren: Port und www entfernen
const domain = host
.split(':')[0]
.replace(/^www\./, '')
.toLowerCase()
.trim()
if (!domain) {
return null
}
// Tenant aus Datenbank suchen
const result = await req.payload.find({
collection: 'tenants',
where: {
domain: { equals: domain }
},
limit: 1,
depth: 0,
})
if (result.docs.length > 0 && result.docs[0]?.id) {
return Number(result.docs[0].id)
}
return null
} catch (error) {
console.error('[TenantAccess] Error resolving tenant from host:', error)
return null
}
}
/**
* Access-Control für öffentlich lesbare, aber tenant-isolierte Collections.
*
* - Authentifizierte Admin-User: Voller Lesezugriff
* - Anonyme Requests: Nur Daten des eigenen Tenants (basierend auf Domain)
*/
export const tenantScopedPublicRead: Access = async ({ req }) => {
// Authentifizierte Admins dürfen alles lesen
if (req.user) {
return true
}
// Anonyme Requests: Tenant aus Domain ermitteln
const tenantId = await getTenantIdFromHost(req)
if (!tenantId) {
// Keine gültige Domain → kein Zugriff
return false
}
// Nur Dokumente des eigenen Tenants zurückgeben
return {
tenant: {
equals: tenantId
}
}
}
/**
* Access-Control: Nur authentifizierte User
*/
export const authenticatedOnly: Access = ({ req }) => {
return !!req.user
}
Aufgabe 3: payload.config.ts aktualisieren
Aktualisiere src/payload.config.ts:
Am Anfang der Datei (nach den Imports) hinzufügen:
typescript// Security: Validate required environment variables at startup
import { env } from './lib/envValidation'
Dann in buildConfig ändern:
typescript// VORHER:
secret: process.env.PAYLOAD_SECRET || '',
// NACHHER:
secret: env.PAYLOAD_SECRET,
Und:
typescript// VORHER:
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
// NACHHER:
db: postgresAdapter({
pool: {
connectionString: env.DATABASE_URI,
},
}),
Aufgabe 4: ConsentLogs.ts komplett ersetzen
Ersetze src/collections/ConsentLogs.ts mit dieser sicheren Version:
typescript// src/collections/ConsentLogs.ts
import type { CollectionConfig } from 'payload'
import crypto from 'crypto'
import { env } from '../lib/envValidation'
import { authenticatedOnly } from '../lib/tenantAccess'
/**
* Generiert einen täglichen, tenant-spezifischen Salt für IP-Anonymisierung.
* Verwendet den sicher validierten Pepper aus der Umgebung.
*/
function getDailySalt(tenantId: string): string {
const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
return crypto
.createHash('sha256')
.update(`${env.IP_ANONYMIZATION_PEPPER}-${tenantId}-${date}`)
.digest('hex')
}
/**
* Anonymisiert eine IP-Adresse mit HMAC-SHA256.
* Der Salt rotiert täglich und ist tenant-spezifisch.
*/
function anonymizeIp(ip: string, tenantId: string): string {
const salt = getDailySalt(tenantId)
return crypto
.createHmac('sha256', salt)
.update(ip)
.digest('hex')
.substring(0, 32) // Gekürzt für Lesbarkeit
}
/**
* Extrahiert die Client-IP aus dem Request.
* Berücksichtigt Reverse-Proxy-Header.
*/
function extractClientIp(req: any): string {
// X-Forwarded-For kann mehrere IPs enthalten (Client, Proxies)
const forwarded = req.headers?.['x-forwarded-for']
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim()
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return String(forwarded[0]).trim()
}
// X-Real-IP (einzelne IP)
const realIp = req.headers?.['x-real-ip']
if (typeof realIp === 'string') {
return realIp.trim()
}
// Fallback: Socket Remote Address
return req.socket?.remoteAddress || req.ip || 'unknown'
}
/**
* ConsentLogs Collection - WORM Audit Trail
*
* Implementiert das Write-Once-Read-Many Prinzip für DSGVO-Nachweispflicht.
* Updates und Deletes sind auf API-Ebene deaktiviert.
*/
export const ConsentLogs: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'consentId',
group: 'Consent Management',
description: 'WORM Audit-Trail für Cookie-Einwilligungen (unveränderbar)',
defaultColumns: ['consentId', 'tenant', 'categories', 'revision', 'createdAt'],
},
// Performance: Keine Versionierung für Audit-Logs
versions: false,
access: {
/**
* CREATE: Nur mit gültigem API-Key.
* Beide Seiten (Header UND Env-Variable) müssen existieren und übereinstimmen.
*/
create: ({ req }) => {
const apiKey = req.headers?.['x-api-key']
// Strikte Validierung: Header muss existieren und non-empty sein
if (!apiKey || typeof apiKey !== 'string') {
return false
}
const trimmedKey = apiKey.trim()
if (trimmedKey === '') {
return false
}
// Vergleich mit validiertem Environment-Wert
// (env.CONSENT_LOGGING_API_KEY ist garantiert non-empty durch envValidation)
return trimmedKey === env.CONSENT_LOGGING_API_KEY
},
/**
* READ: Nur authentifizierte Admin-User
*/
read: authenticatedOnly,
/**
* UPDATE: WORM - Niemals erlaubt
*/
update: () => false,
/**
* DELETE: WORM - Niemals über API erlaubt
* (Nur via Retention-Job mit direktem DB-Zugriff)
*/
delete: () => false,
},
hooks: {
beforeChange: [
({ data, req, operation }) => {
// Nur bei Neuanlage
if (operation !== 'create') {
return data
}
// 1. Server-generierte Consent-ID (Trust Boundary)
data.consentId = crypto.randomUUID()
// 2. IP anonymisieren
const rawIp = data.ip || extractClientIp(req)
const tenantId = typeof data.tenant === 'object'
? String(data.tenant.id)
: String(data.tenant)
data.anonymizedIp = anonymizeIp(rawIp, tenantId)
// Rohe IP NIEMALS speichern
delete data.ip
// 3. Ablaufdatum setzen (3 Jahre Retention gemäß DSGVO)
const expiresAt = new Date()
expiresAt.setFullYear(expiresAt.getFullYear() + 3)
data.expiresAt = expiresAt.toISOString()
// 4. User Agent kürzen (Datensparsamkeit)
if (data.userAgent && typeof data.userAgent === 'string') {
data.userAgent = data.userAgent.substring(0, 500)
}
return data
},
],
},
fields: [
{
name: 'consentId',
type: 'text',
required: true,
unique: true,
admin: {
readOnly: true,
description: 'Server-generierte eindeutige ID',
},
},
{
name: 'clientRef',
type: 'text',
admin: {
readOnly: true,
description: 'Client-seitige Referenz (Cookie-UUID) für Traceability',
},
},
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'categories',
type: 'json',
required: true,
admin: {
readOnly: true,
description: 'Akzeptierte Kategorien zum Zeitpunkt der Einwilligung',
},
},
{
name: 'revision',
type: 'number',
required: true,
admin: {
readOnly: true,
description: 'Version der Konfiguration zum Zeitpunkt der Zustimmung',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
readOnly: true,
description: 'Browser/Device (für Forensik und Bot-Erkennung)',
},
},
{
name: 'anonymizedIp',
type: 'text',
admin: {
readOnly: true,
description: 'HMAC-Hash der IP (täglich rotierender, tenant-spezifischer Salt)',
},
},
{
name: 'expiresAt',
type: 'date',
required: true,
admin: {
readOnly: true,
description: 'Automatische Löschung nach 3 Jahren',
date: {
pickerAppearance: 'dayOnly',
},
},
},
],
}
Aufgabe 5: CookieConfigurations.ts aktualisieren
Ersetze src/collections/CookieConfigurations.ts:
typescript// src/collections/CookieConfigurations.ts
import type { CollectionConfig } from 'payload'
import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'
/**
* CookieConfigurations Collection
*
* Mandantenspezifische Cookie-Banner-Konfiguration.
* Öffentlich lesbar, aber nur für den eigenen Tenant (Domain-basiert).
*/
export const CookieConfigurations: CollectionConfig = {
slug: 'cookie-configurations',
admin: {
useAsTitle: 'title',
group: 'Consent Management',
description: 'Cookie-Banner Konfiguration pro Tenant',
},
access: {
// Öffentlich, aber tenant-isoliert (Domain-Check)
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
unique: true,
admin: {
description: 'Jeder Tenant kann nur eine Konfiguration haben',
},
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Cookie-Einstellungen',
admin: {
description: 'Interner Titel zur Identifikation',
},
},
{
name: 'revision',
type: 'number',
required: true,
defaultValue: 1,
admin: {
description: 'Bei inhaltlichen Änderungen erhöhen → erzwingt erneuten Consent bei allen Nutzern',
},
},
{
name: 'enabledCategories',
type: 'select',
hasMany: true,
required: true,
defaultValue: ['necessary', 'analytics'],
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
admin: {
description: 'Welche Kategorien sollen im Banner angezeigt werden?',
},
},
{
name: 'translations',
type: 'group',
fields: [
{
name: 'de',
type: 'group',
label: 'Deutsch',
fields: [
{
name: 'bannerTitle',
type: 'text',
defaultValue: 'Wir respektieren Ihre Privatsphäre',
},
{
name: 'bannerDescription',
type: 'textarea',
defaultValue: 'Diese Website verwendet Cookies, um Ihnen die bestmögliche Erfahrung zu bieten.',
},
{
name: 'acceptAllButton',
type: 'text',
defaultValue: 'Alle akzeptieren',
},
{
name: 'acceptNecessaryButton',
type: 'text',
defaultValue: 'Nur notwendige',
},
{
name: 'settingsButton',
type: 'text',
defaultValue: 'Einstellungen',
},
{
name: 'saveButton',
type: 'text',
defaultValue: 'Auswahl speichern',
},
{
name: 'privacyPolicyUrl',
type: 'text',
defaultValue: '/datenschutz',
},
{
name: 'categoryLabels',
type: 'group',
fields: [
{
name: 'necessary',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Notwendig' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies sind für die Grundfunktionen der Website erforderlich.' },
],
},
{
name: 'functional',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Funktional' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies ermöglichen erweiterte Funktionen.' },
],
},
{
name: 'analytics',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Statistik' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies helfen uns zu verstehen, wie Besucher die Website nutzen.' },
],
},
{
name: 'marketing',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Marketing' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies werden für Werbezwecke verwendet.' },
],
},
],
},
],
},
],
},
{
name: 'styling',
type: 'group',
fields: [
{
name: 'position',
type: 'select',
defaultValue: 'bottom',
options: [
{ label: 'Unten', value: 'bottom' },
{ label: 'Oben', value: 'top' },
{ label: 'Mitte (Modal)', value: 'middle' },
],
},
{
name: 'theme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dunkel', value: 'dark' },
{ label: 'Hell', value: 'light' },
{ label: 'Auto (System)', value: 'auto' },
],
},
],
},
],
}
Aufgabe 6: CookieInventory.ts aktualisieren
Ersetze src/collections/CookieInventory.ts:
typescript// src/collections/CookieInventory.ts
import type { CollectionConfig } from 'payload'
import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'
/**
* CookieInventory Collection
*
* Dokumentation aller verwendeten Cookies für die Datenschutzerklärung.
* Öffentlich lesbar, aber nur für den eigenen Tenant (Domain-basiert).
*/
export const CookieInventory: CollectionConfig = {
slug: 'cookie-inventory',
admin: {
useAsTitle: 'name',
group: 'Consent Management',
description: 'Cookie-Dokumentation für die Datenschutzerklärung',
defaultColumns: ['name', 'provider', 'category', 'duration', 'tenant'],
},
access: {
// Öffentlich, aber tenant-isoliert (Domain-Check)
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
},
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Technischer Name des Cookies (z.B. "_ga")',
},
},
{
name: 'provider',
type: 'text',
required: true,
admin: {
description: 'Anbieter (z.B. "Google LLC")',
},
},
{
name: 'category',
type: 'select',
required: true,
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
},
{
name: 'duration',
type: 'text',
required: true,
admin: {
description: 'Speicherdauer (z.B. "2 Jahre")',
},
},
{
name: 'description',
type: 'textarea',
required: true,
admin: {
description: 'Verständliche Erklärung für Endnutzer',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
},
],
}
Aufgabe 7: GraphQL Playground entfernen
Lösche die Datei:
bashrm -f src/app/\(payload\)/api/graphql-playground/route.ts
Falls das Verzeichnis danach leer ist:
bashrmdir src/app/\(payload\)/api/graphql-playground/ 2>/dev/null || true
Aufgabe 8: Build und Test
Nach allen Änderungen:
bash# TypeScript kompilieren und prüfen
pnpm build
# Bei Erfolg: PM2 neu starten
pm2 restart payload
# Logs prüfen (sollte ohne Fehler starten)
pm2 logs payload --lines 20 --nostream
Aufgabe 9: Sicherheitstest
Teste die Fixes:
bash# 1. Tenant-Isolation testen (sollte 403 oder leeres Array zurückgeben)
curl -s "http://localhost:3000/api/cookie-configurations" | head -c 200
# 2. Mit korrektem Host-Header (sollte Daten für porwoll.de zeigen)
curl -s -H "Host: porwoll.de" "http://localhost:3000/api/cookie-configurations" | head -c 200
# 3. Consent-Log ohne API-Key (sollte 403 zurückgeben)
curl -X POST "http://localhost:3000/api/consent-logs" \
-H "Content-Type: application/json" \
-d '{"tenant":1,"categories":["necessary"],"revision":1}'
# 4. Consent-Log mit korrektem API-Key (sollte 201 zurückgeben)
curl -X POST "http://localhost:3000/api/consent-logs" \
-H "Content-Type: application/json" \
-H "x-api-key: $(grep CONSENT_LOGGING_API_KEY .env | cut -d= -f2)" \
-d '{"tenant":1,"categories":["necessary"],"revision":1,"clientRef":"test-123"}'
# 5. GraphQL Playground (sollte 404 zurückgeben)
curl -s "http://localhost:3000/api/graphql-playground"
Zusammenfassung der Änderungen
DateiAktionZwecksrc/lib/envValidation.tsNEUFail-Fast für fehlende Env-Varssrc/lib/tenantAccess.tsNEUDomain-basierte Tenant-Isolationsrc/payload.config.tsÄNDERNImport envValidation, sichere Secret-Verwendungsrc/collections/ConsentLogs.tsERSETZENStrikte API-Key-Prüfung, kein Pepper-Fallbacksrc/collections/CookieConfigurations.tsERSETZENTenant-scoped Read Accesssrc/collections/CookieInventory.tsERSETZENTenant-scoped Read Accesssrc/app/(payload)/api/graphql-playground/route.tsLÖSCHENKein Schema-Leak in Production
Erwartetes Ergebnis
Server startet NUR wenn alle Env-Vars gesetzt sind
Anonyme API-Requests sehen nur Daten ihres Tenants
ConsentLogs nur mit validem API-Key beschreibbar
GraphQL Playground nicht mehr erreichbar
IP-Anonymisierung ohne unsichere Fallbacks

View file

@ -1,6 +1,6 @@
# Security-Richtlinien - Payload CMS Multi-Tenant
> Letzte Aktualisierung: 08.12.2025
> Letzte Aktualisierung: 09.12.2025
## Übersicht
@ -255,10 +255,30 @@ pnpm test:unit
---
## Custom Login Route
Das Admin Panel verwendet eine Custom Login Route (`src/app/(payload)/api/users/login/route.ts`) mit folgenden Features:
- **Audit-Logging:** Jeder Login-Versuch wird in AuditLogs protokolliert
- **Rate-Limiting:** 5 Versuche pro 15 Minuten (authLimiter)
- **Content-Type Support:**
- JSON (`application/json`)
- FormData mit `_payload` JSON-Feld (Payload Admin Panel Format)
- Standard FormData (`multipart/form-data`)
- URL-encoded (`application/x-www-form-urlencoded`)
**Sicherheitsaspekte:**
- Passwort wird nie in Logs/Responses exponiert
- Fehlgeschlagene Login-Versuche werden mit IP und User-Agent geloggt
- Rate-Limiting verhindert Brute-Force-Angriffe
---
## Änderungshistorie
| Datum | Änderung |
|-------|----------|
| 09.12.2025 | Custom Login Route Dokumentation, multipart/form-data _payload Support |
| 08.12.2025 | Security Test Suite (143 Tests) |
| 07.12.2025 | Rate Limiter, CSRF, IP Allowlist, Data Masking |
| 07.12.2025 | Pre-Commit Hook, GitHub Actions Workflow |

View file

@ -297,10 +297,19 @@
- [x] Test Utilities (`tests/helpers/security-test-utils.ts`)
- [x] Dedicated Script: `pnpm test:security`
- [x] CI Integration in `.github/workflows/security.yml`
- [ ] **Test-Suite erweitern**
- [ ] Test-DB mit Migrationen aufsetzen
- [ ] Skipped Tests aktivieren (email-logs, i18n)
- [ ] Coverage-Report generieren
- [x] **Test-Suite erweitern** (Erledigt: 09.12.2025)
- [x] Test-DB mit Migrationen aufsetzen (Locale-Tabellen bereits vorhanden)
- [x] Skipped Tests aktivieren (search, i18n) - alle 9 Tests nun aktiv
- [x] Coverage-Report generieren (`pnpm test:coverage`)
- Vitest v8 Coverage Provider konfiguriert
- HTML/LCOV/Text Reports in `./coverage/`
- Thresholds: 35% lines, 50% functions, 65% branches
- Aktuell: 37.29% lines, 55.55% functions, 71.61% branches
- [x] **Audit-Fixes** (Erledigt: 10.12.2025)
- [x] Vitest auf 3.2.4 aktualisiert (Version-Warnung entfernt)
- [x] `payload.create`/`payload.update` Mock in `tests/int/security-api.int.spec.ts` ergänzt
- [x] 205 Tests laufen fehlerfrei, Coverage-Report ohne Abbrüche
- [x] Kein "`payload.create is not a function`" Hinweis mehr im Test-Output
- [ ] **CI/CD Pipeline**
- [x] GitHub Actions Workflow erstellt (security.yml)
- [ ] Automatisches Lint/Test/Build Workflow
@ -316,10 +325,10 @@
- [x] Formularvalidierung für SMTP-Settings (Host-Format, Port-Bereich, Pflichtfelder)
- [x] Tooltips für SPF/DKIM-Hinweise (aufklappbare Info-Komponente mit Beispielen)
- [x] "Test-Email senden" Button (Custom UI-Komponente + API-Endpoint)
- [ ] **Tenant Self-Service**
- [ ] API für Tenant-Admins zum Testen der SMTP-Settings
- [ ] Email-Logs Einsicht für eigenen Tenant
- [ ] Eigene Statistiken Dashboard
- [x] **Tenant Self-Service** (Erledigt: 08.12.2025)
- [x] API für Tenant-Admins zum Testen der SMTP-Settings (`/api/test-email`)
- [x] Email-Logs Einsicht für eigenen Tenant (Tenant-basierter Zugriff bereits vorhanden)
- [x] Eigene Statistiken Dashboard (`/admin/tenant-dashboard`)
#### Data Retention
- [ ] **Automatische Datenbereinigung**
@ -438,4 +447,16 @@
---
*Letzte Aktualisierung: 08.12.2025 (Email-Konfiguration UX implementiert)*
*Letzte Aktualisierung: 09.12.2025*
---
## Changelog
### 09.12.2025
- **Admin Login Fix:** Custom Login-Route unterstützt nun `_payload` JSON-Feld aus multipart/form-data (Payload Admin Panel Format)
- **Dokumentation bereinigt:** Obsolete PROMPT_*.md Instruktionsdateien gelöscht
- **CLAUDE.md aktualisiert:** Security-Features, Test Suite, AuditLogs dokumentiert
### 10.12.2025
- **Audit-Fixes:** Vitest auf 3.2.4 aktualisiert, Payload-Mocks im Security-Test ergänzt

View file

@ -1,939 +0,0 @@
# TECHSTACK DOKUMENTATION - DEZEMBER 2025
## Infrastruktur-Gesamtübersicht
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ GESAMTARCHITEKTUR │
│ │
│ ┌───────────────────────────────────────────────────────────────────────────────┐ │
│ │ LOKALE ENTWICKLUNGSUMGEBUNG │ │
│ │ (Proxmox VE Cluster) │ │
│ │ LAN: 10.10.181.0/24 │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ sv-payload │ │ sv-postgres │ │sv-dev-payload│ │sv-analytics │ │ │
│ │ │ LXC 700 │ │ LXC 701 │ │ LXC 702 │ │ LXC 703 │ │ │
│ │ │ Payload CMS │ │ PostgreSQL │ │ Next.js │ │ Umami │ │ │
│ │ │10.10.181.100│ │10.10.181.101│ │10.10.181.102│ │10.10.181.103│ │ │
│ │ │ + Redis │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ │ LOKALER INTERNETZUGANG │ │
│ │ 850 Mbps ↓ / 50 Mbps ↑ │ │
│ │ │ │
│ │ Feste IP-Adressen: │ │
│ │ 37.24.237.178 - Router │ │
│ │ 37.24.237.179 - complexcaresolutions │ │
│ │ 37.24.237.180 - Nginx Proxy Manager │ │
│ │ 37.24.237.181 - pl.c2sgmbh.de │ │
│ │ 37.24.237.182 - frei │ │
│ └───────────────────┬───────────────────┘ │
│ │ │
│ INTERNET │
│ │ │
│ ┌──────────────────────────────────┼──────────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ HETZNER 1 │ │ HETZNER 2 │ │ HETZNER 3 │ │
│ │ CCS GmbH │ │ Martin Porwoll │ │ Backend/Analytics │ │
│ │ │ │ │ │ │ │
│ │ 78.46.87.137 │ │ 94.130.141.114 │ │ 162.55.85.18 │ │
│ │ Debian 12.12 │ │ Ubuntu 24.04 │ │ Debian 13 │ │
│ │ Plesk │ │ Plesk │ │ Native │ │
│ │ │ │ │ │ │ │
│ │ Next.js Frontends │ │ Next.js Frontends │ │ ✅ Payload CMS │ │
│ │ • complexcare... │ │ • porwoll.de │ │ ✅ Umami │ │
│ │ • gunshin.de │ │ • caroline-... │ │ ✅ PostgreSQL 17 │ │
│ └─────────────────────┘ └─────────────────────┘ │ ✅ Redis Cache │ │
│ │ ✅ Claude Code │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
---
## Server-Details
### HETZNER 3 - Backend & Analytics (NEU)
| Eigenschaft | Wert |
|-------------|------|
| **Hostname** | sv-hz03-backend |
| **IP-Adresse** | 162.55.85.18 |
| **Betriebssystem** | Debian 13 "Trixie" |
| **CPU** | AMD Ryzen 5 3600 (6 Cores / 12 Threads) |
| **RAM** | 64 GB DDR4 ECC |
| **Storage** | 2x 512 GB NVMe SSD (Software RAID 1) |
| **Netzwerk** | 1 Gbit/s (garantiert) |
| **Traffic** | Unbegrenzt |
| **Kosten** | ~€52/Monat |
#### Services auf Hetzner 3
| Service | User | Port | URL | Status |
|---------|------|------|-----|--------|
| PostgreSQL 17 | postgres | 5432 | localhost | ✅ Läuft |
| Payload CMS | payload | 3001 | https://cms.c2sgmbh.de | ✅ Läuft |
| Umami Analytics | umami | 3000 | https://analytics.c2sgmbh.de | ✅ Läuft |
| Redis Cache | redis | 6379 | localhost | ✅ Läuft |
| Nginx | root | 80/443 | Reverse Proxy | ✅ Läuft |
| Claude Code | claude | - | CLI Tool | ✅ Installiert |
#### System-User
| User | Zweck | Home-Verzeichnis |
|------|-------|------------------|
| root | System-Administration | /root |
| payload | Payload CMS | /home/payload |
| umami | Umami Analytics | /home/umami |
| claude | Claude Code / Server-Admin | /home/claude |
#### SSH-Zugang
```bash
ssh root@162.55.85.18
ssh payload@162.55.85.18
ssh umami@162.55.85.18
ssh claude@162.55.85.18
```
---
### HETZNER 1 - Complex Care Solutions GmbH
| Eigenschaft | Wert |
|-------------|------|
| **Eigentümer** | Complex Care Solutions GmbH |
| **IP-Adresse** | 78.46.87.137 |
| **Betriebssystem** | Debian 12.12 |
| **Control Panel** | Plesk Web Pro Edition 18.0.73 |
| **CPU** | AMD Ryzen 7 Pro 8700GE |
| **RAM** | 64 GB |
| **Storage** | 2x 512 GB NVMe SSD (Software RAID 1) |
| **Max. Domains** | 30 |
#### Domains auf Hetzner 1
| Domain | DNS/Weiterleitung | Zweck |
|--------|-------------------|-------|
| **complexcaresolutions.de** | A: 78.46.87.137 | Hauptdomain |
| complexcaresolutions.at/ch/eu/nl | → complexcaresolutions.de | Redirects |
| complexcaresolutions.org | A: 78.46.87.137 | Alternate |
| complex-care-solutions.com | A: 78.46.87.137 | International |
| **gunshin.de** | Vorlage: Standard | Portfolio/Holding |
| c2sgmbh.de | → complexcaresolutions.de | Kurzform |
| zweitmeinung-*.de | → complexcaresolutions.de | Fachgebiete |
---
### HETZNER 2 - Martin Porwoll (privat)
| Eigenschaft | Wert |
|-------------|------|
| **Eigentümer** | Martin Porwoll (privat) |
| **IP-Adresse** | 94.130.141.114 |
| **Betriebssystem** | Ubuntu 24.04 LTS |
| **Control Panel** | Plesk Web Pro Edition 18.0.73 |
| **CPU** | Intel Xeon E3-1275v6 |
| **RAM** | 64 GB |
| **Storage** | 2x 512 GB NVMe SSD (Software RAID 1) |
| **Max. Domains** | 30 |
#### Domains auf Hetzner 2
| Domain | DNS/Weiterleitung | Zweck |
|--------|-------------------|-------|
| **porwoll.de** | A: 94.130.141.114 | Hauptdomain |
| **caroline-porwoll.de** | A: 94.130.141.114 | Dr. Caroline Porwoll |
| caroline-porwoll.com | A: 94.130.141.114 | International |
| porwoll.com/cloud/live/shop/tech | Vorlage: Standard | Varianten |
---
### Lokale Infrastruktur (Proxmox)
| Server | IP | Port | Funktion | OS |
|--------|-----|------|----------|-----|
| sv-payload | 10.10.181.100 | 3000 | Payload CMS (Dev) + Redis | Debian 13 |
| sv-postgres | 10.10.181.101 | 5432 | PostgreSQL (Dev) | Debian 13 |
| sv-dev-payload | 10.10.181.102 | 3001 | Next.js Frontend | Debian 13 |
| sv-analytics | 10.10.181.103 | 3000 | Umami (Dev) | Debian 13 |
#### Feste IP-Adressen (Lokal)
| IP | Verwendung |
|----|------------|
| 37.24.237.178 | Router / Gateway |
| 37.24.237.179 | complexcaresolutions.cloud |
| 37.24.237.180 | Nginx Proxy Manager |
| 37.24.237.181 | pl.c2sgmbh.de (Payload Dev) |
| 37.24.237.182 | **Frei** |
---
## Credentials
### sv-hz03-backend (162.55.85.18) - Produktion
#### PostgreSQL
| Datenbank | User | Passwort |
|-----------|------|----------|
| payload_db | payload | Suchen55 |
| umami_db | umami | Suchen55 |
#### Redis
```bash
redis-cli -h localhost -p 6379
# Kein Passwort (nur localhost)
```
#### Umami Analytics
| URL | User | Passwort |
|-----|------|----------|
| https://analytics.c2sgmbh.de | admin | ⚠️ ÄNDERN! (Standard: umami) |
#### Payload CMS
| URL | User | Passwort |
|-----|------|----------|
| https://cms.c2sgmbh.de/admin | [wie Dev] | [wie Dev] |
#### Environment Variables - Payload (.env)
```env
DATABASE_URI=postgresql://payload:Suchen55@localhost:5432/payload_db
PAYLOAD_SECRET=hxPARlMkmv+ZdCOAMw+N4o2x4mNbERB237iDQTYXALY=
PAYLOAD_PUBLIC_SERVER_URL=https://cms.c2sgmbh.de
NEXT_PUBLIC_SERVER_URL=https://cms.c2sgmbh.de
NODE_ENV=production
PORT=3001
CONSENT_LOGGING_API_KEY=7644095c1be9b726ac6c1433c7a544f4d99b55337d70f52c8dc85a4b76ef9f1a
IP_ANONYMIZATION_PEPPER=18f2d29f1ead67f15fec88ee2357565a6c0073394bcd085ef636f877954bd546
REDIS_HOST=localhost
REDIS_PORT=6379
```
#### Environment Variables - Umami (.env)
```env
DATABASE_URL=postgresql://umami:Suchen55@localhost:5432/umami_db
APP_SECRET=aqwsOyaH/1IyWHby+Ni5e5IIt/soJwvWcfxMM6kwYS0=
TRACKER_SCRIPT_NAME=custom
COLLECT_API_ENDPOINT=/api/send
DISABLE_TELEMETRY=1
```
---
### pl.c2sgmbh.de (Entwicklung)
#### PostgreSQL (sv-postgres)
| Datenbank | User | Passwort |
|-----------|------|----------|
| payload_db | payload | Finden55 |
#### Redis (sv-payload)
```bash
redis-cli -h localhost -p 6379
# Kein Passwort (nur localhost)
```
#### Environment Variables (.env)
```env
DATABASE_URI=postgresql://payload:Finden55@10.10.181.101:5432/payload_db
PAYLOAD_SECRET=a53b254070d3fffd2b5cfcc3
PAYLOAD_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
NEXT_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
NODE_ENV=production
PORT=3000
CONSENT_LOGGING_API_KEY=7644095c1be9b726ac6c1433c7a544f4d99b55337d70f52c8dc85a4b76ef9f1a
IP_ANONYMIZATION_PEPPER=18f2d29f1ead67f15fec88ee2357565a6c0073394bcd085ef636f877954bd546
REDIS_HOST=localhost
REDIS_PORT=6379
```
---
## Redis Caching
### Architektur
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ REDIS CACHING STRATEGIE │
│ │
│ Request → Payload CMS → Redis Cache? │
│ │ │
│ ┌────┴────┐ │
│ HIT MISS │
│ │ │ │
│ ▼ ▼ │
│ Return PostgreSQL → Cache in Redis → Return │
│ │
│ Cache-Typen: │
│ • API Response Cache (GET /api/pages, /api/posts) │
│ • Automatische Invalidierung bei Content-Änderungen │
│ │
│ Konfiguration: │
│ • Max Memory: 2GB (Prod) / 512MB (Dev) │
│ • Eviction: allkeys-lru │
│ • TTL: 5 Minuten (Standard) │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
### Redis Befehle
```bash
# Status prüfen
redis-cli ping
# Statistiken
redis-cli info stats
# Cache-Keys anzeigen
redis-cli keys "*"
# Cache leeren
redis-cli flushdb
# Live-Monitoring
redis-cli monitor
```
### Cache-Dateien im Projekt
```
src/
├── lib/
│ ├── redis.ts # Redis Client & Cache Helper
│ └── cache-keys.ts # Cache Key Definitionen
└── hooks/
└── invalidateCache.ts # Cache Invalidierung bei Content-Änderungen
```
---
## Claude Code
### Installation auf sv-hz03-backend
```bash
ssh claude@162.55.85.18
claude
```
### CLAUDE.md Standort
```
/home/claude/CLAUDE.md
```
### Berechtigungen
| Berechtigung | Status |
|--------------|--------|
| sudo systemctl restart nginx | ✅ NOPASSWD |
| sudo systemctl restart postgresql | ✅ NOPASSWD |
| sudo systemctl status * | ✅ NOPASSWD |
| sudo su - payload | ✅ NOPASSWD |
| sudo su - umami | ✅ NOPASSWD |
| sudo redis-cli * | ✅ NOPASSWD |
### Häufige Claude Code Aufgaben
```bash
# Service-Status
sudo su - payload -c "pm2 status"
sudo systemctl status nginx postgresql redis-server
# Logs
sudo su - payload -c "pm2 logs payload"
sudo tail -f /var/log/nginx/error.log
# Deployment
sudo su - payload -c "~/deploy.sh"
# Backup
sudo su - payload -c "~/backup.sh"
# Redis Monitor
sudo redis-cli monitor
```
---
## Git & GitHub
### Repository
| Eigenschaft | Wert |
|-------------|------|
| **Repository** | https://github.com/c2s-admin/cms.c2sgmbh.git |
| **Visibility** | Private |
| **Owner** | c2s-admin |
| **Branch** | main |
### GitHub CLI Installation
**Auf Debian/Ubuntu:**
```bash
# GPG-Schlüssel hinzufügen
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
# Repository hinzufügen
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list
# Installation
sudo apt update
sudo apt install gh -y
```
### GitHub Authentifizierung
```bash
# Mit Web-Authentifizierung
gh auth login --web
# Status prüfen
gh auth status
```
### Git-Konfiguration
**Remote Repository:**
```bash
# HTTPS (empfohlen für gh auth)
git remote add origin https://github.com/c2s-admin/cms.c2sgmbh.git
# Remote prüfen
git remote -v
# Remote URL ändern (falls nötig)
git remote set-url origin https://github.com/c2s-admin/cms.c2sgmbh.git
```
**SSH-Keys (Alternative):**
```bash
# SSH-Key generieren
ssh-keygen -t ed25519 -C "payload@c2sgmbh.de"
# Public Key zu GitHub hinzufügen
cat ~/.ssh/id_ed25519.pub
# → Auf GitHub.com: Settings → SSH and GPG keys → New SSH key
# SSH Remote verwenden
git remote set-url origin git@github.com:c2s-admin/cms.c2sgmbh.git
```
### .gitignore (Wichtig!)
Sensible Dateien, die NICHT committed werden dürfen:
```gitignore
# Environment Variables
.env
.env*.local
# Build-Ausgaben
/.next/
/build
/out
# Dependencies
/node_modules
# Backups & Datenbanken
*.sql
*.sql.gz
/backups/
# Media-Uploads
/media
# Logs
*.log
```
### Git Workflow
**Entwicklung (pl.c2sgmbh.de):**
```bash
cd /home/payload/payload-cms
# Status prüfen
git status
# Änderungen stagen
git add .
# Commit erstellen
git commit -m "feat: Beschreibung der Änderung"
# Zu GitHub pushen
git push origin main
```
**Commit Message Konventionen:**
```
feat: Neues Feature
fix: Bugfix
chore: Wartung/Cleanup
docs: Dokumentation
refactor: Code-Refactoring
style: Formatierung
test: Tests
```
### Nützliche Git-Befehle
```bash
# Letzte Commits anzeigen
git log --oneline -10
# Änderungen anzeigen
git diff
git diff --staged
# Änderungen rückgängig machen
git restore <file> # Unstaged Änderungen verwerfen
git restore --staged <file> # Aus Staging entfernen
# Branch-Info
git branch -a
git status
# Von GitHub pullen
git pull origin main
# Merge-Konflikte prüfen
git diff --name-only --diff-filter=U
```
### GitHub CLI Befehle
```bash
# Repository anzeigen
gh repo view
gh repo view --web
# Issues
gh issue list
gh issue create
# Pull Requests
gh pr list
gh pr create
# Repository klonen
gh repo clone c2s-admin/cms.c2sgmbh
```
### Backup über Git (Ausnahme!)
**Normalerweise:** SQL-Dateien werden NICHT committed (`.gitignore`)
**Ausnahme für Server-Migration:**
```bash
# Backup erzwingen (einmalig!)
git add -f backup.sql
git commit -m "chore: temporary database backup for migration"
git push
# ⚠️ WICHTIG: Nach Transfer wieder entfernen!
git rm backup.sql
git commit -m "chore: remove database backup after migration"
git push
# Optional: Aus Git-Historie komplett löschen
git filter-branch --force --index-filter \
"git rm --cached --ignore-unmatch backup.sql" \
--prune-empty --tag-name-filter cat -- --all
git push origin --force --all
```
---
## Deployment Workflow
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT WORKFLOW │
│ │
│ ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ ENTWICKLUNG (DEV) │ │ PRODUKTION (PROD) │ │
│ │ pl.c2sgmbh.de │ │ cms.c2sgmbh.de │ │
│ │ 37.24.237.181 │ │ 162.55.85.18 │ │
│ │ 10.10.181.100 (LAN) │ │ │ │
│ └──────────────────────────────────┘ └──────────────────────────────────┘ │
│ │
│ Step 1: CODE ENTWICKELN │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ cd /home/payload/payload-cms │ │
│ │ # Code ändern, testen │ │
│ │ pnpm dev # Lokal testen │ │
│ │ pnpm build # Build-Test │ │
│ │ pm2 restart payload # Auf Dev-Server deployen │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ Step 2: ZU GITHUB PUSHEN │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ git status # Änderungen prüfen │ │
│ │ git add . # Alle Änderungen stagen │ │
│ │ git commit -m "feat: XYZ" # Commit erstellen │ │
│ │ git push origin main # Zu GitHub pushen │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ GITHUB REPOSITORY (PRIVAT) │ │
│ │ https://github.com/c2s-admin/cms.c2sgmbh │ │
│ │ │ │
│ │ ✅ Code gesichert │ │
│ │ ✅ Versionierung │ │
│ │ ✅ .env in .gitignore │ │
│ │ ✅ Backup SQL (temporär, nach Transfer löschen) │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ Step 3: AUF PRODUKTION DEPLOYEN │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ ssh payload@162.55.85.18 │ │
│ │ ~/deploy.sh # Automatisches Deployment │ │
│ │ │ │
│ │ Das deploy.sh Script macht: │ │
│ │ ├─ git pull origin main # Code von GitHub holen │ │
│ │ ├─ pnpm install # Dependencies aktualisieren │ │
│ │ ├─ pnpm build # Produktions-Build │ │
│ │ └─ pm2 restart payload # Service neustarten │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ │
│ Step 4: VERIFIZIERUNG │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ pm2 status # Prozess läuft? │ │
│ │ pm2 logs payload --lines 20 # Logs prüfen │ │
│ │ curl https://cms.c2sgmbh.de/api/globals/site-settings │ │
│ │ # Browser: https://cms.c2sgmbh.de/admin │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
### Git-Setup auf Servern
| Server | User | Remote | Auth-Methode | Status |
|--------|------|--------|--------------|--------|
| pl.c2sgmbh.de (Dev) | payload | HTTPS | GitHub CLI (`gh auth`) | ✅ Konfiguriert |
| cms.c2sgmbh.de (Prod) | payload | SSH | SSH-Key | ✅ Eingerichtet |
### Deployment-Befehle
**Entwicklungsserver → GitHub:**
```bash
# Auf pl.c2sgmbh.de (10.10.181.100)
cd /home/payload/payload-cms
# 1. Änderungen prüfen
git status
git diff
# 2. Build-Test lokal
pnpm build
pm2 restart payload
# 3. Testen
curl https://pl.c2sgmbh.de/api/globals/site-settings
# 4. Zu Git committen
git add .
git commit -m "feat: Beschreibung der Änderung"
# 5. Zu GitHub pushen
git push origin main
```
**GitHub → Produktionsserver:**
```bash
# Option A: SSH + Deploy-Script (empfohlen)
ssh payload@162.55.85.18 '~/deploy.sh'
# Option B: Manuelles SSH-Login
ssh payload@162.55.85.18
cd ~/payload-cms
git pull origin main
pnpm install
pnpm build
pm2 restart payload
pm2 logs payload --lines 20
```
### Deploy-Script (~/deploy.sh)
```bash
#!/bin/bash
set -e
echo "🚀 Deployment gestartet..."
cd ~/payload-cms
echo "📥 Git Pull..."
git pull origin main
echo "📦 Dependencies installieren..."
pnpm install
echo "🔨 Build erstellen..."
pnpm build
echo "🔄 PM2 Neustart..."
pm2 restart payload
echo "✅ Deployment abgeschlossen!"
pm2 status
```
---
## Backup
### Backup-Script (~/backup.sh)
```bash
#!/bin/bash
set -e
BACKUP_DIR=~/backups
DATE=$(date +%Y-%m-%d_%H-%M-%S)
RETENTION_DAYS=7
mkdir -p $BACKUP_DIR
echo "🔄 Backup gestartet: $DATE"
# PostgreSQL Backup
PGPASSWORD=Suchen55 pg_dump -h localhost -U payload payload_db > $BACKUP_DIR/payload_db_$DATE.sql
PGPASSWORD=Suchen55 pg_dump -h localhost -U umami umami_db > $BACKUP_DIR/umami_db_$DATE.sql
# Komprimieren
gzip $BACKUP_DIR/payload_db_$DATE.sql
gzip $BACKUP_DIR/umami_db_$DATE.sql
# Alte Backups löschen
find $BACKUP_DIR -name "*.sql.gz" -mtime +$RETENTION_DAYS -delete
echo "✅ Backup abgeschlossen!"
ls -lh $BACKUP_DIR/*.sql.gz 2>/dev/null | tail -10
```
### Cronjob (täglich 3:00 Uhr)
```
0 3 * * * /home/payload/backup.sh >> /home/payload/backups/backup.log 2>&1
```
### Backup-Speicherort
```
/home/payload/backups/
├── payload_db_2025-12-05_03-00-00.sql.gz
├── umami_db_2025-12-05_03-00-00.sql.gz
└── backup.log
```
---
## Service-Management
### PM2 Befehle
```bash
# Status
pm2 status
# Logs
pm2 logs payload
pm2 logs umami
# Neustart
pm2 restart payload
pm2 restart umami
# Alle neustarten
pm2 restart all
# Speichern für Autostart
pm2 save
```
### Systemd Services
```bash
# PostgreSQL
systemctl status postgresql
systemctl restart postgresql
# Nginx
systemctl status nginx
systemctl restart nginx
nginx -t # Config testen
# Redis
systemctl status redis-server
systemctl restart redis-server
```
---
## URLs Übersicht
| Service | Entwicklung | Produktion |
|---------|-------------|------------|
| Payload Admin | https://pl.c2sgmbh.de/admin | https://cms.c2sgmbh.de/admin |
| Payload API | https://pl.c2sgmbh.de/api | https://cms.c2sgmbh.de/api |
| Umami | - | https://analytics.c2sgmbh.de |
---
## SSH Schnellzugriff
```bash
# Produktion (Hetzner 3)
ssh root@162.55.85.18 # Root
ssh payload@162.55.85.18 # Payload User
ssh umami@162.55.85.18 # Umami User
ssh claude@162.55.85.18 # Claude Code
# Hetzner Server
ssh root@78.46.87.137 # Hetzner 1 (CCS)
ssh root@94.130.141.114 # Hetzner 2 (Porwoll)
# Entwicklung (Proxmox)
ssh payload@10.10.181.100 # sv-payload
ssh root@10.10.181.101 # sv-postgres
ssh developer@10.10.181.102 # sv-dev-payload
ssh root@10.10.181.103 # sv-analytics
```
---
## Wichtige Dateipfade
### sv-hz03-backend (Produktion)
```
/home/payload/
├── payload-cms/ # Payload CMS
│ ├── .env # Environment
│ ├── src/ # Source Code
│ │ ├── lib/
│ │ │ ├── redis.ts # Redis Client
│ │ │ └── cache-keys.ts # Cache Keys
│ │ └── hooks/
│ │ └── invalidateCache.ts
│ └── .next/ # Build Output
├── deploy.sh # Deployment Script
├── backup.sh # Backup Script
└── backups/ # Backups
/home/umami/
└── umami/ # Umami Analytics
├── .env
└── .next/
/home/claude/
└── CLAUDE.md # Claude Code Kontext
```
---
## Firewall (UFW)
```bash
ufw status verbose
# Offene Ports auf sv-hz03-backend:
# 22/tcp - SSH
# 80/tcp - HTTP
# 443/tcp - HTTPS
```
---
## SSL Zertifikate
| Domain | Anbieter | Ablauf |
|--------|----------|--------|
| cms.c2sgmbh.de | Let's Encrypt | 2026-03-05 |
| analytics.c2sgmbh.de | Let's Encrypt | 2026-03-05 |
Auto-Renewal via Certbot Timer.
---
## Tech Stack
| Komponente | Technologie | Version |
|------------|-------------|---------|
| CMS | Payload CMS | 3.66.0 |
| Framework | Next.js | 15.4.7 |
| Runtime | Node.js | 22.x |
| Datenbank | PostgreSQL | 17.6 |
| Cache | Redis | 7.x |
| Analytics | Umami | 3.x |
| Process Manager | PM2 | Latest |
| Package Manager | pnpm | Latest |
| Reverse Proxy | Nginx | Latest |
| SSL | Let's Encrypt | - |
| Server Admin | Claude Code | 2.0.59 |
---
## Notfall-Kontakte
Bei Problemen:
1. **Logs prüfen:** `pm2 logs`
2. **Services neustarten:** `pm2 restart all`
3. **Nginx prüfen:** `nginx -t && systemctl restart nginx`
4. **PostgreSQL prüfen:** `systemctl status postgresql`
5. **Redis prüfen:** `redis-cli ping`
6. **Claude Code nutzen:** `ssh claude@162.55.85.18``claude`
---
## Checkliste nach Deployment
- [ ] `pm2 status` - Alle Prozesse online?
- [ ] `redis-cli ping` - Redis antwortet?
- [ ] https://cms.c2sgmbh.de/admin - Admin erreichbar?
- [ ] https://analytics.c2sgmbh.de - Umami erreichbar?
- [ ] `pm2 logs payload --lines 10` - Keine Fehler?
---
*Stand: 05. Dezember 2025*
*Server: sv-hz03-backend (162.55.85.18)*
*Setup: Payload CMS + Umami + PostgreSQL + Redis + Claude Code*

View file

@ -17,6 +17,7 @@
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts tests/unit",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts tests/int",
"test:security": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts tests/unit/security tests/int/security-api.int.spec.ts",
"test:coverage": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts --coverage",
"test:e2e": "test -f .next/BUILD_ID || (echo 'Error: No build found. Run pnpm build first.' && exit 1) && cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test",
"prepare": "test -d .git && (ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit 2>/dev/null || true) || true"
},
@ -53,6 +54,7 @@
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@vitejs/plugin-react": "4.5.2",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.4.7",
"jsdom": "26.1.0",
@ -61,7 +63,7 @@
"prettier": "^3.2.5",
"typescript": "5.7.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.3"
"vitest": "3.2.4"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",

View file

@ -99,6 +99,9 @@ importers:
'@vitejs/plugin-react':
specifier: 4.5.2
version: 4.5.2(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6))
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6))
eslint:
specifier: ^9.16.0
version: 9.39.1
@ -124,11 +127,15 @@ importers:
specifier: 5.1.4
version: 5.1.4(typescript@5.7.3)(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6))
vitest:
specifier: 3.2.3
version: 3.2.3(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6)
specifier: 3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6)
packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
@ -352,6 +359,10 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@borewit/text-codec@0.1.1':
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
@ -1106,6 +1117,14 @@ packages:
'@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -1367,6 +1386,10 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
@ -1945,11 +1968,20 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0
'@vitest/expect@3.2.3':
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
peerDependencies:
'@vitest/browser': 3.2.4
vitest: 3.2.4
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/mocker@3.2.3':
resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==}
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
@ -1959,23 +1991,20 @@ packages:
vite:
optional: true
'@vitest/pretty-format@3.2.3':
resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==}
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.3':
resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.3':
resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.3':
resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.3':
resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@ -2006,6 +2035,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@ -2014,6 +2047,10 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
@ -2067,6 +2104,9 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
ast-v8-to-istanbul@0.3.8:
resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@ -2475,9 +2515,15 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.260:
resolution: {integrity: sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@ -2752,6 +2798,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -2806,6 +2856,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@ -2880,6 +2934,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@ -3000,6 +3057,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@ -3075,10 +3136,29 @@ packages:
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jose@5.9.6:
resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==}
@ -3215,6 +3295,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
@ -3335,6 +3422,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
@ -3454,6 +3545,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -3479,6 +3573,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@ -3892,6 +3990,10 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@ -3952,6 +4054,14 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@ -3978,6 +4088,14 @@ packages:
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@ -4030,6 +4148,10 @@ packages:
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
@ -4226,8 +4348,8 @@ packages:
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
vite-node@3.2.3:
resolution: {integrity: sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
@ -4279,16 +4401,16 @@ packages:
yaml:
optional: true
vitest@3.2.3:
resolution: {integrity: sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==}
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.3
'@vitest/ui': 3.2.3
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
@ -4357,6 +4479,14 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@ -4408,6 +4538,11 @@ packages:
snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@apidevtools/json-schema-ref-parser@11.9.3':
dependencies:
'@jsdevtools/ono': 7.1.3
@ -4934,6 +5069,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@borewit/text-codec@0.1.1': {}
'@csstools/color-helpers@5.1.0': {}
@ -5499,6 +5636,17 @@ snapshots:
'@ioredis/commands@1.4.0': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -5983,6 +6131,9 @@ snapshots:
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
@ -6634,49 +6785,64 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.3':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.8
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.21
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6)
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.3
'@vitest/utils': 3.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.3(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6))':
'@vitest/mocker@3.2.4(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6))':
dependencies:
'@vitest/spy': 3.2.3
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6)
'@vitest/pretty-format@3.2.3':
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.3':
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.3
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.3':
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.3
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.3':
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.3':
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.3
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
@ -6706,12 +6872,16 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
@ -6796,6 +6966,12 @@ snapshots:
ast-types-flow@0.0.8: {}
ast-v8-to-istanbul@0.3.8:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 9.0.1
async-function@1.0.0: {}
atomic-sleep@1.0.0: {}
@ -7098,8 +7274,12 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.260: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
end-of-stream@1.4.5:
@ -7290,8 +7470,8 @@ snapshots:
'@typescript-eslint/parser': 8.48.0(eslint@9.39.1)(typescript@5.7.3)
eslint: 9.39.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1)
eslint-plugin-react: 7.37.5(eslint@9.39.1)
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1)
@ -7310,7 +7490,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@ -7321,22 +7501,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.48.0(eslint@9.39.1)(typescript@5.7.3)
eslint: 9.39.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -7347,7 +7527,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -7557,6 +7737,11 @@ snapshots:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fsevents@2.3.2:
optional: true
@ -7620,6 +7805,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
globals@14.0.0: {}
globalthis@1.0.4:
@ -7680,6 +7874,8 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@ -7808,6 +8004,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@ -7880,6 +8078,27 @@ snapshots:
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@ -7889,6 +8108,12 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jose@5.9.6: {}
joycon@3.1.1: {}
@ -8024,6 +8249,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.3
marked@14.0.0: {}
math-intrinsics@1.1.0: {}
@ -8277,6 +8512,8 @@ snapshots:
minimist@1.2.8: {}
minipass@7.1.2: {}
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
@ -8400,6 +8637,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@ -8431,6 +8670,11 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-to-regexp@6.3.0: {}
path-type@4.0.0: {}
@ -8969,6 +9213,8 @@ snapshots:
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
@ -9016,6 +9262,18 @@ snapshots:
streamsearch@1.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.8
@ -9071,6 +9329,14 @@ snapshots:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@ -9107,6 +9373,12 @@ snapshots:
tabbable@6.3.0: {}
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.5.0
minimatch: 9.0.5
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
@ -9330,7 +9602,7 @@ snapshots:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
vite-node@3.2.3(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6):
vite-node@3.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6):
dependencies:
cac: 6.7.14
debug: 4.4.3
@ -9376,16 +9648,16 @@ snapshots:
sass: 1.77.4
tsx: 4.20.6
vitest@3.2.3(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(jsdom@26.1.0)(sass@1.77.4)(tsx@4.20.6):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.3
'@vitest/mocker': 3.2.3(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6))
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.3
'@vitest/snapshot': 3.2.3
'@vitest/spy': 3.2.3
'@vitest/utils': 3.2.3
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.2.2
@ -9399,7 +9671,7 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6)
vite-node: 3.2.3(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6)
vite-node: 3.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
@ -9488,6 +9760,18 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
wrappy@1.0.2: {}
ws@8.18.3: {}

View file

@ -26,8 +26,10 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { TenantBreadcrumb as TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3 } from '@/components/admin/TenantBreadcrumb'
import { DashboardNavLink as DashboardNavLink_3987d42d9edba53cc710fb1f6cc541b5 } from '@/components/admin/DashboardNavLink'
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { TenantDashboardView as TenantDashboardView_1468a86d093d5b2444131ed5ce14599e } from '@/components/admin/TenantDashboardView'
export const importMap = {
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
@ -58,6 +60,8 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/components/admin/TenantBreadcrumb#TenantBreadcrumb": TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3,
"@/components/admin/DashboardNavLink#DashboardNavLink": DashboardNavLink_3987d42d9edba53cc710fb1f6cc541b5,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62,
"@/components/admin/TenantDashboardView#TenantDashboardView": TenantDashboardView_1468a86d093d5b2444131ed5ce14599e
}

View file

@ -15,6 +15,7 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
import { logAccessDenied } from '@/lib/audit/audit-service'
import { maskSmtpError } from '@/lib/security/data-masking'
interface UserWithTenants {
id: number
@ -168,7 +169,8 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
id: doc.id,
to: doc.to,
subject: doc.subject,
error: doc.error,
// Security: Mask sensitive data in SMTP error messages before exposing to tenant admins
error: maskSmtpError(doc.error as string | null | undefined),
createdAt: doc.createdAt,
tenantId:
typeof doc.tenant === 'object' && doc.tenant

View file

@ -0,0 +1,59 @@
'use client'
import React from 'react'
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
/**
* Navigation Link zum Tenant Dashboard
* Wird nur angezeigt, wenn ein Tenant ausgewählt ist
*/
export const DashboardNavLink: React.FC = () => {
const { selectedTenantID } = useTenantSelection()
// Nur anzeigen, wenn ein Tenant ausgewählt ist
if (!selectedTenantID) {
return null
}
return (
<a
href="/admin/tenant-dashboard"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
color: 'var(--theme-elevation-800)',
textDecoration: 'none',
fontSize: '0.875rem',
borderBottom: '1px solid var(--theme-elevation-100)',
transition: 'background-color 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span>Tenant Dashboard</span>
</a>
)
}
export default DashboardNavLink

View file

@ -0,0 +1,376 @@
.tenant-dashboard {
padding: 1.5rem;
max-width: 1200px;
&--empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: var(--theme-elevation-500);
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
&__title-section {
flex: 1;
min-width: 200px;
}
&__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-elevation-800);
}
&__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--theme-elevation-500);
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
&__period-select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-elevation-200);
border-radius: var(--style-radius-s, 4px);
background-color: var(--theme-input-background);
font-size: 0.875rem;
color: var(--theme-elevation-800);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--theme-success-500);
}
}
&__refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--theme-elevation-200);
border-radius: var(--style-radius-s, 4px);
background-color: var(--theme-input-background);
cursor: pointer;
color: var(--theme-elevation-600);
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-elevation-100);
color: var(--theme-elevation-800);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__refresh-icon {
&--spinning {
animation: tenant-dashboard-spin 1s linear infinite;
}
}
&__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: var(--theme-elevation-500);
}
&__spinner {
width: 24px;
height: 24px;
border: 2px solid var(--theme-elevation-200);
border-top-color: var(--theme-success-500);
border-radius: 50%;
animation: tenant-dashboard-spin 0.8s linear infinite;
}
&__error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background-color: rgba(var(--theme-error-500-rgb), 0.1);
border: 1px solid var(--theme-error-500);
border-radius: var(--style-radius-s, 4px);
color: var(--theme-error-600);
font-size: 0.875rem;
}
&__stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
&__stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background-color: var(--theme-elevation-50);
border: 1px solid var(--theme-elevation-150);
border-radius: var(--style-radius-m, 8px);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--success {
border-left: 3px solid var(--theme-success-500);
}
&--error {
border-left: 3px solid var(--theme-error-500);
}
&--rate {
border-left: 3px solid var(--theme-elevation-500);
}
}
&__stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--style-radius-s, 4px);
flex-shrink: 0;
&--total {
background-color: rgba(var(--theme-elevation-500-rgb), 0.1);
color: var(--theme-elevation-600);
}
&--sent {
background-color: rgba(var(--theme-success-500-rgb), 0.1);
color: var(--theme-success-600);
}
&--failed {
background-color: rgba(var(--theme-error-500-rgb), 0.1);
color: var(--theme-error-600);
}
&--rate {
background-color: rgba(var(--theme-elevation-500-rgb), 0.1);
color: var(--theme-elevation-600);
}
}
&__stat-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
&__stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--theme-elevation-800);
line-height: 1;
}
&__stat-label {
font-size: 0.8125rem;
color: var(--theme-elevation-500);
}
&__section {
margin-bottom: 1.5rem;
padding: 1.25rem;
background-color: var(--theme-elevation-50);
border: 1px solid var(--theme-elevation-150);
border-radius: var(--style-radius-m, 8px);
&--failures {
border-left: 3px solid var(--theme-warning-500);
}
}
&__section-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--theme-elevation-700);
}
&__source-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
&__source-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: var(--theme-elevation-100);
border-radius: var(--style-radius-s, 4px);
}
&__source-label {
font-size: 0.8125rem;
color: var(--theme-elevation-600);
}
&__source-value {
font-size: 1rem;
font-weight: 600;
color: var(--theme-elevation-800);
}
&__failures-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__failure-item {
padding: 0.75rem;
background-color: rgba(var(--theme-error-500-rgb), 0.05);
border: 1px solid rgba(var(--theme-error-500-rgb), 0.2);
border-radius: var(--style-radius-s, 4px);
}
&__failure-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
&__failure-to {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-elevation-800);
}
&__failure-date {
font-size: 0.75rem;
color: var(--theme-elevation-500);
}
&__failure-subject {
font-size: 0.8125rem;
color: var(--theme-elevation-600);
margin-bottom: 0.25rem;
}
&__failure-error {
font-size: 0.75rem;
color: var(--theme-error-600);
font-family: var(--font-mono);
padding: 0.5rem;
background-color: rgba(var(--theme-error-500-rgb), 0.1);
border-radius: 2px;
overflow-x: auto;
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding-top: 0.5rem;
}
&__action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background-color: var(--theme-elevation-100);
border: 1px solid var(--theme-elevation-200);
border-radius: var(--style-radius-s, 4px);
font-size: 0.875rem;
color: var(--theme-elevation-700);
text-decoration: none;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-elevation-150);
border-color: var(--theme-elevation-300);
color: var(--theme-elevation-800);
}
}
}
@keyframes tenant-dashboard-spin {
to {
transform: rotate(360deg);
}
}
// Dark mode support
:global(.dark) .tenant-dashboard {
&__stat-card {
background-color: var(--theme-elevation-100);
border-color: var(--theme-elevation-200);
}
&__section {
background-color: var(--theme-elevation-100);
border-color: var(--theme-elevation-200);
}
&__source-item {
background-color: var(--theme-elevation-150);
}
}
// Responsive
@media (max-width: 768px) {
.tenant-dashboard {
padding: 1rem;
&__header {
flex-direction: column;
}
&__stats-grid {
grid-template-columns: repeat(2, 1fr);
}
&__stat-card {
padding: 1rem;
}
&__stat-icon {
width: 40px;
height: 40px;
}
&__stat-value {
font-size: 1.5rem;
}
}
}

View file

@ -0,0 +1,318 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
import './TenantDashboard.scss'
interface EmailStats {
total: number
sent: number
failed: number
pending: number
successRate: number
}
interface SourceStats {
manual: number
form: number
system: number
newsletter: number
}
interface RecentFailure {
id: number
to: string
subject: string
error: string
createdAt: string
}
interface StatsResponse {
success: boolean
period: string
periodStart: string
stats: EmailStats
bySource: SourceStats
recentFailures: RecentFailure[]
}
type Period = '24h' | '7d' | '30d'
/**
* Tenant Self-Service Dashboard
* Zeigt E-Mail-Statistiken und Quick-Actions für Tenant-Admins
*/
export const TenantDashboard: React.FC = () => {
const { selectedTenantID, options } = useTenantSelection()
const [stats, setStats] = useState<StatsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [period, setPeriod] = useState<Period>('7d')
// Aktueller Tenant-Name
const currentTenant = options?.find((opt) => opt.value === selectedTenantID)
const tenantName = currentTenant?.label || 'Unbekannt'
const fetchStats = useCallback(async () => {
if (!selectedTenantID) {
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const response = await fetch(
`/api/email-logs/stats?tenantId=${selectedTenantID}&period=${period}`,
{
credentials: 'include',
},
)
if (!response.ok) {
throw new Error('Fehler beim Laden der Statistiken')
}
const data = await response.json()
setStats(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}, [selectedTenantID, period])
useEffect(() => {
fetchStats()
}, [fetchStats])
// Formatiere Datum
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Periode-Labels
const periodLabels: Record<Period, string> = {
'24h': 'Letzte 24 Stunden',
'7d': 'Letzte 7 Tage',
'30d': 'Letzte 30 Tage',
}
if (!selectedTenantID) {
return (
<div className="tenant-dashboard tenant-dashboard--empty">
<p>Bitte wählen Sie einen Tenant aus, um das Dashboard anzuzeigen.</p>
</div>
)
}
return (
<div className="tenant-dashboard">
<div className="tenant-dashboard__header">
<div className="tenant-dashboard__title-section">
<h2 className="tenant-dashboard__title">Dashboard: {tenantName}</h2>
<p className="tenant-dashboard__subtitle">E-Mail-Statistiken und Übersicht</p>
</div>
<div className="tenant-dashboard__controls">
<select
className="tenant-dashboard__period-select"
value={period}
onChange={(e) => setPeriod(e.target.value as Period)}
>
{Object.entries(periodLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<button
className="tenant-dashboard__refresh-btn"
onClick={fetchStats}
disabled={loading}
title="Aktualisieren"
>
<svg
className={`tenant-dashboard__refresh-icon ${loading ? 'tenant-dashboard__refresh-icon--spinning' : ''}`}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" />
</svg>
</button>
</div>
</div>
{loading && !stats && (
<div className="tenant-dashboard__loading">
<div className="tenant-dashboard__spinner" />
<span>Lade Statistiken...</span>
</div>
)}
{error && (
<div className="tenant-dashboard__error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{error}</span>
</div>
)}
{stats && (
<>
{/* Statistik-Karten */}
<div className="tenant-dashboard__stats-grid">
<div className="tenant-dashboard__stat-card">
<div className="tenant-dashboard__stat-icon tenant-dashboard__stat-icon--total">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</div>
<div className="tenant-dashboard__stat-content">
<span className="tenant-dashboard__stat-value">{stats.stats.total}</span>
<span className="tenant-dashboard__stat-label">Gesamt</span>
</div>
</div>
<div className="tenant-dashboard__stat-card tenant-dashboard__stat-card--success">
<div className="tenant-dashboard__stat-icon tenant-dashboard__stat-icon--sent">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div className="tenant-dashboard__stat-content">
<span className="tenant-dashboard__stat-value">{stats.stats.sent}</span>
<span className="tenant-dashboard__stat-label">Gesendet</span>
</div>
</div>
<div className="tenant-dashboard__stat-card tenant-dashboard__stat-card--error">
<div className="tenant-dashboard__stat-icon tenant-dashboard__stat-icon--failed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
<div className="tenant-dashboard__stat-content">
<span className="tenant-dashboard__stat-value">{stats.stats.failed}</span>
<span className="tenant-dashboard__stat-label">Fehlgeschlagen</span>
</div>
</div>
<div className="tenant-dashboard__stat-card tenant-dashboard__stat-card--rate">
<div className="tenant-dashboard__stat-icon tenant-dashboard__stat-icon--rate">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="tenant-dashboard__stat-content">
<span className="tenant-dashboard__stat-value">{stats.stats.successRate}%</span>
<span className="tenant-dashboard__stat-label">Erfolgsrate</span>
</div>
</div>
</div>
{/* Aufschlüsselung nach Quelle */}
<div className="tenant-dashboard__section">
<h3 className="tenant-dashboard__section-title">Nach Quelle</h3>
<div className="tenant-dashboard__source-grid">
<div className="tenant-dashboard__source-item">
<span className="tenant-dashboard__source-label">Formular</span>
<span className="tenant-dashboard__source-value">{stats.bySource.form}</span>
</div>
<div className="tenant-dashboard__source-item">
<span className="tenant-dashboard__source-label">Manuell (API)</span>
<span className="tenant-dashboard__source-value">{stats.bySource.manual}</span>
</div>
<div className="tenant-dashboard__source-item">
<span className="tenant-dashboard__source-label">System</span>
<span className="tenant-dashboard__source-value">{stats.bySource.system}</span>
</div>
<div className="tenant-dashboard__source-item">
<span className="tenant-dashboard__source-label">Newsletter</span>
<span className="tenant-dashboard__source-value">{stats.bySource.newsletter}</span>
</div>
</div>
</div>
{/* Letzte Fehler */}
{stats.recentFailures.length > 0 && (
<div className="tenant-dashboard__section tenant-dashboard__section--failures">
<h3 className="tenant-dashboard__section-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Letzte fehlgeschlagene E-Mails
</h3>
<div className="tenant-dashboard__failures-list">
{stats.recentFailures.map((failure) => (
<div key={failure.id} className="tenant-dashboard__failure-item">
<div className="tenant-dashboard__failure-header">
<span className="tenant-dashboard__failure-to">{failure.to}</span>
<span className="tenant-dashboard__failure-date">
{formatDate(failure.createdAt)}
</span>
</div>
<div className="tenant-dashboard__failure-subject">{failure.subject}</div>
{failure.error && (
<div className="tenant-dashboard__failure-error">{failure.error}</div>
)}
</div>
))}
</div>
</div>
)}
{/* Quick Actions */}
<div className="tenant-dashboard__actions">
<a
href={`/admin/collections/email-logs?where[tenant][equals]=${selectedTenantID}`}
className="tenant-dashboard__action-btn"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Alle E-Mail-Logs anzeigen
</a>
<a
href={`/admin/collections/tenants/${selectedTenantID}`}
className="tenant-dashboard__action-btn"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
E-Mail-Einstellungen
</a>
</div>
</>
)}
</div>
)
}
export default TenantDashboard

View file

@ -0,0 +1,14 @@
'use client'
import React from 'react'
import { TenantDashboard } from './TenantDashboard'
/**
* Wrapper-Komponente für das Tenant Dashboard als Custom View
* Wird in payload.config.ts als Custom View registriert
*/
export const TenantDashboardView: React.FC = () => {
return <TenantDashboard />
}
export default TenantDashboardView

View file

@ -33,6 +33,15 @@ interface SendEmailOptions extends EmailOptions {
// Cache für SMTP-Transporter
const transporterCache = new Map<string, Transporter>()
/**
* Prüft ob der SMTP-Versand übersprungen werden soll
* - wird automatisch in Test-Umgebungen deaktiviert
* - kann via EMAIL_DELIVERY_DISABLED explizit deaktiviert werden
*/
function isEmailDeliveryDisabled(): boolean {
return process.env.NODE_ENV === 'test' || process.env.EMAIL_DELIVERY_DISABLED === 'true'
}
/**
* Globaler Fallback-Transporter aus .env Variablen
*/
@ -207,6 +216,24 @@ export async function sendTenantEmail(
metadata: options.metadata,
})
// E-Mail-Versand in Tests oder wenn explizit deaktiviert: sofort Erfolg melden
if (isEmailDeliveryDisabled()) {
const mockMessageId = `test-message-${Date.now()}`
if (logId) {
await updateEmailLog(payload, logId, {
status: 'sent',
messageId: mockMessageId,
})
}
console.info('[Email] Delivery disabled - skipping SMTP send')
return {
success: true,
messageId: mockMessageId,
logId: logId || undefined,
}
}
// Transporter wählen (Tenant-spezifisch oder global)
const transporter = getTenantTransporter(tenant)

View file

@ -131,7 +131,22 @@ export function validateCsrf(req: NextRequest): {
return originResult
}
// 3. Für API-Requests ohne Browser (Content-Type: application/json ohne Origin)
// 3. Payload Admin Panel: Hat eigenen CSRF-Schutz
// Requests vom Admin-Panel (Referer enthält /admin) werden durchgelassen
const referer = req.headers.get('referer')
if (referer) {
try {
const refererUrl = new URL(referer)
if (refererUrl.pathname.startsWith('/admin')) {
// Admin-Panel-Request - Payload hat eigenen CSRF-Schutz
return { valid: true }
}
} catch {
// Ungültige Referer-URL ignorieren
}
}
// 4. Für API-Requests ohne Browser (Content-Type: application/json ohne Origin)
// können wir auf CSRF-Token verzichten wenn Authorization-Header vorhanden
const hasAuth = req.headers.get('authorization')
const isJsonRequest = req.headers.get('content-type')?.includes('application/json')
@ -141,7 +156,7 @@ export function validateCsrf(req: NextRequest): {
return { valid: true }
}
// 4. Browser-Requests: CSRF-Token validieren
// 5. Browser-Requests: CSRF-Token validieren
const tokenFromHeader = req.headers.get(CSRF_TOKEN_HEADER)
const tokenFromCookie = req.cookies.get(CSRF_COOKIE_NAME)?.value

View file

@ -46,7 +46,7 @@ const SENSITIVE_FIELD_NAMES = [
]
// Patterns für sensible Werte (in Strings)
const SENSITIVE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
const SENSITIVE_PATTERNS: Array<{ pattern: RegExp; replacement: string | ((match: string) => string) }> = [
// Passwörter in verschiedenen Formaten
{ pattern: /password['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'password: [REDACTED]' },
{ pattern: /pass['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'pass: [REDACTED]' },
@ -59,8 +59,19 @@ const SENSITIVE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
// Authorization Headers
{ pattern: /authorization['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'authorization: [REDACTED]' },
// SMTP Credentials
// SMTP Credentials - Multiple formats
{ pattern: /smtp[_-]?pass(?:word)?['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'smtp_pass: [REDACTED]' },
{ pattern: /auth[_-]?pass(?:word)?['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'auth_pass: [REDACTED]' },
// SMTP Error Messages - Common patterns from nodemailer/SMTP providers
// AUTH LOGIN failed: Username and Password not accepted
{ pattern: /AUTH\s+(?:LOGIN|PLAIN|CRAM-MD5)\s+failed[^.]*password[^.]*\./gi, replacement: 'AUTH failed: [CREDENTIALS REDACTED].' },
// 535 5.7.8 Authentication credentials invalid
{ pattern: /535\s+[\d.]+\s+[^:]*(?:credentials?|authentication)[^.]*/gi, replacement: '535 Authentication error: [DETAILS REDACTED]' },
// Invalid login: 535-5.7.8 Username and Password not accepted
{ pattern: /Invalid\s+login[^:]*:[^.]*/gi, replacement: 'Invalid login: [DETAILS REDACTED]' },
// SMTP username/password patterns in error stack traces
{ pattern: /user(?:name)?['":\s]*['"]?[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+['"]?/gi, replacement: 'user: [REDACTED]' },
// Connection Strings mit Passwörtern
{ pattern: /:\/\/[^:]+:([^@]+)@/g, replacement: '://[USER]:[REDACTED]@' },
@ -74,6 +85,9 @@ const SENSITIVE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
return `${parts[0]}.[PAYLOAD REDACTED].[SIGNATURE REDACTED]`
}},
// Base64 encoded credentials (common in SMTP AUTH) - minimum 16 chars to avoid false positives
{ pattern: /(?:AUTH|auth)\s+(?:PLAIN|LOGIN|CRAM-MD5)?\s*[A-Za-z0-9+/=]{16,}/gi, replacement: 'AUTH [BASE64 REDACTED]' },
// E-Mail-Adressen teilweise maskieren (für Datenschutz, nicht Security)
// Deaktiviert - E-Mails werden für Audit-Logs benötigt
]
@ -207,6 +221,45 @@ export function maskEmailLogData(emailLog: Record<string, unknown>): Record<stri
return masked
}
/**
* Maskiert SMTP Error-Messages für sichere Anzeige im Dashboard
*
* Speziell für den Fall, dass SMTP-Server Credential-Details in Fehlern zurückgeben.
* Behält nützliche Debugging-Informationen, entfernt aber sensible Daten.
*/
export function maskSmtpError(error: string | null | undefined): string | null {
if (!error || typeof error !== 'string') return null
// Leere oder sehr kurze Errors durchlassen
if (error.length < 3) return error
// Bekannte SMTP-Fehler-Kategorien mit benutzerfreundlichen Ersetzungen
let masked = error
// Allgemeine maskString-Patterns zuerst anwenden
masked = maskString(masked)
// Zusätzliche SMTP-spezifische Patterns
// Hostnames/IPs in SMTP-Antworten beibehalten, aber Auth-Details entfernen
// "530 5.7.0 Must issue a STARTTLS command first" - behalten
// "535 5.7.8 Error: authentication failed" - behalten (ohne Details)
// "454 4.7.0 Too many login attempts, please try later" - behalten
// Entferne detaillierte Auth-Failure-Gründe aber behalte Fehlercode
masked = masked.replace(
/(\d{3}\s+[\d.]+\s+).*(?:Username|Password|Credentials?|Authentication).*$/gi,
'$1Authentication failed'
)
// Truncate exzessiv lange Errors (Stack traces etc.)
const MAX_ERROR_LENGTH = 200
if (masked.length > MAX_ERROR_LENGTH) {
masked = masked.substring(0, MAX_ERROR_LENGTH) + '...'
}
return masked
}
/**
* Maskiert Error-Objekte für sichere Logs
*/

View file

@ -212,15 +212,39 @@ export interface Tenant {
* SMTP-Einstellungen für diesen Tenant. Leer = globale Einstellungen.
*/
email?: {
/**
* Tipp: Verwenden Sie eine E-Mail-Adresse der Domain, für die SPF/DKIM konfiguriert ist.
*/
fromAddress?: string | null;
fromName?: string | null;
replyTo?: string | null;
/**
* Aktivieren Sie diese Option, um einen eigenen SMTP-Server statt der globalen Einstellungen zu verwenden.
*/
useCustomSmtp?: boolean | null;
/**
* Hinweis: Stellen Sie sicher, dass SPF- und DKIM-Einträge für Ihre Domain konfiguriert sind, um eine optimale E-Mail-Zustellung zu gewährleisten.
*/
smtp?: {
host?: string | null;
/**
* Hostname ohne Protokoll (z.B. smtp.gmail.com)
*/
host: string;
/**
* 587 (STARTTLS) oder 465 (SSL)
*/
port?: number | null;
/**
* Für Port 465 aktivieren
*/
secure?: boolean | null;
user?: string | null;
/**
* Meist die E-Mail-Adresse
*/
user: string;
/**
* Leer lassen um bestehendes Passwort zu behalten
*/
pass?: string | null;
};
};

View file

@ -61,6 +61,20 @@ export default buildConfig({
components: {
// Tenant-Kontext in der Admin-Header-Leiste anzeigen
afterNavLinks: ['@/components/admin/TenantBreadcrumb#TenantBreadcrumb'],
// Custom Views
views: {
// Tenant Self-Service Dashboard
tenantDashboard: {
Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
path: '/tenant-dashboard',
meta: {
title: 'Tenant Dashboard',
description: 'E-Mail-Statistiken und Übersicht für Ihren Tenant',
},
},
},
// Navigation um Dashboard-Link zu ergänzen
beforeNavLinks: ['@/components/admin/DashboardNavLink#DashboardNavLink'],
},
},
// Multi-Tenant Email Adapter

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { Payload } from 'payload'
import type { Tenant } from '@/payload-types'
@ -21,14 +21,18 @@ import {
describe('tenant email service', () => {
let payload: Payload
let mockFindByID: ReturnType<typeof vi.fn>
let mockCreate: ReturnType<typeof vi.fn>
beforeEach(() => {
mockSendMail.mockClear()
mockCreateTransport.mockClear()
mockFindByID = vi.fn()
mockCreate = vi.fn().mockResolvedValue({ id: 1 })
payload = {
findByID: mockFindByID,
create: mockCreate,
update: vi.fn().mockResolvedValue({}),
} as unknown as Payload
process.env.SMTP_HOST = 'smtp.global.test'
@ -41,99 +45,186 @@ describe('tenant email service', () => {
invalidateGlobalEmailCache()
})
it('falls back to global SMTP configuration when tenant has no custom SMTP', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.global.test',
port: 587,
secure: false,
auth: {
user: 'global-user',
pass: 'global-pass',
},
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: '"Tenant A" <noreply@example.com>',
to: 'user@example.com',
}),
)
afterEach(() => {
// Restore NODE_ENV after each test
delete process.env.EMAIL_DELIVERY_DISABLED
})
it('uses cached tenant-specific transporter and re-creates it after invalidation', async () => {
const tenant = {
id: 42,
slug: 'tenant-b',
name: 'Tenant B',
email: {
useCustomSmtp: true,
fromAddress: 'info@tenant-b.de',
fromName: 'Tenant B',
smtp: {
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
describe('with EMAIL_DELIVERY_DISABLED=false (production mode)', () => {
beforeEach(() => {
// Override test environment to simulate production
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('EMAIL_DELIVERY_DISABLED', 'false')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('falls back to global SMTP configuration when tenant has no custom SMTP', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.global.test',
port: 587,
secure: false,
auth: {
user: 'global-user',
pass: 'global-pass',
},
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: '"Tenant A" <noreply@example.com>',
to: 'user@example.com',
}),
)
})
it('uses cached tenant-specific transporter and re-creates it after invalidation', async () => {
const tenant = {
id: 42,
slug: 'tenant-b',
name: 'Tenant B',
email: {
useCustomSmtp: true,
fromAddress: 'info@tenant-b.de',
fromName: 'Tenant B',
smtp: {
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
user: 'tenant-user',
pass: 'tenant-pass',
},
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi',
text: 'First email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
auth: {
user: 'tenant-user',
pass: 'tenant-pass',
},
},
} as Tenant
})
mockFindByID.mockResolvedValue(tenant)
mockCreateTransport.mockClear()
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi',
text: 'First email',
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi again',
text: 'Second email',
})
expect(mockCreateTransport).not.toHaveBeenCalled()
invalidateTenantEmailCache(tenant.id)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'After invalidation',
text: 'Third email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
})
})
describe('with EMAIL_DELIVERY_DISABLED=true (test mode)', () => {
it('skips SMTP delivery and returns synthetic message ID', async () => {
// NODE_ENV=test is default in vitest, which disables email delivery
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(result.messageId).toMatch(/^test-message-\d+$/)
// SMTP should NOT be called in test mode
expect(mockCreateTransport).not.toHaveBeenCalled()
expect(mockSendMail).not.toHaveBeenCalled()
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
auth: {
user: 'tenant-user',
pass: 'tenant-pass',
},
it('creates email log even when delivery is disabled', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
// Email log should still be created
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'email-logs',
data: expect.objectContaining({
to: 'user@example.com',
subject: 'Test',
status: 'pending',
}),
}),
)
})
})
describe('cache invalidation', () => {
it('invalidates tenant-specific cache', () => {
// This is a simple function that clears cache - just verify it doesn't throw
expect(() => invalidateTenantEmailCache(42)).not.toThrow()
})
mockCreateTransport.mockClear()
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi again',
text: 'Second email',
it('invalidates global cache', () => {
expect(() => invalidateGlobalEmailCache()).not.toThrow()
})
expect(mockCreateTransport).not.toHaveBeenCalled()
invalidateTenantEmailCache(tenant.id)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'After invalidation',
text: 'Third email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
})
})

View file

@ -90,9 +90,7 @@ describe('Payload Localization Integration', () => {
// These tests verify the configuration is correct
})
// Note: These tests require the localization migration to be run first
// They will fail with "relation posts_locales does not exist" until migration is complete
describe.skip('Search with Locale (requires migration)', () => {
describe('Search with Locale', () => {
beforeAll(async () => {
const payloadConfig = await config
payload = await getPayload({ config: payloadConfig })

View file

@ -102,8 +102,7 @@ describe('Search Library', () => {
})
})
// Skip searchPosts tests until localization migration is complete
describe.skip('searchPosts (requires migration)', () => {
describe('searchPosts', () => {
it('returns empty results for non-matching query', async () => {
const result = await searchPosts(payload, {
query: 'xyznonexistent12345',
@ -153,8 +152,7 @@ describe('Search Library', () => {
expect(result).toEqual([])
})
// Skip tests that require localization migration
it.skip('respects limit parameter (requires migration)', async () => {
it('respects limit parameter', async () => {
const result = await getSearchSuggestions(payload, {
query: 'test',
limit: 3,
@ -164,8 +162,7 @@ describe('Search Library', () => {
})
})
// Skip tests that require localization migration
describe.skip('getPostsByCategory (requires migration)', () => {
describe('getPostsByCategory', () => {
it('returns paginated results', async () => {
const result = await getPostsByCategory(payload, {
page: 1,
@ -198,8 +195,7 @@ describe('Search Library', () => {
})
})
// Skip Search API Integration tests until localization migration is complete
describe.skip('Search API Integration (requires migration)', () => {
describe('Search API Integration', () => {
let testCategoryId: number | null = null
let testPostId: number | null = null
let testTenantId: number | null = null

View file

@ -565,6 +565,8 @@ describe('Security API Integration', () => {
},
}),
findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
create: vi.fn().mockResolvedValue({ id: 1 }),
update: vi.fn().mockResolvedValue({}),
}),
}))

View file

@ -194,6 +194,7 @@ describe('CSRF Protection', () => {
csrfCookie?: string
authorization?: string
contentType?: string
referer?: string
} = {},
): NextRequest {
const headers = new Headers()
@ -214,6 +215,10 @@ describe('CSRF Protection', () => {
headers.set('content-type', options.contentType)
}
if (options.referer) {
headers.set('referer', options.referer)
}
const url = 'https://test.example.com/api/test'
const request = new NextRequest(url, {
method,
@ -289,6 +294,39 @@ describe('CSRF Protection', () => {
})
})
describe('Admin Panel Requests', () => {
it('allows requests from /admin without CSRF token', () => {
const req = createMockRequest('POST', {
referer: 'https://test.example.com/admin/login',
})
const result = validateCsrf(req)
expect(result.valid).toBe(true)
})
it('allows requests from /admin subpaths without CSRF token', () => {
const req = createMockRequest('POST', {
referer: 'https://test.example.com/admin/collections/users',
})
const result = validateCsrf(req)
expect(result.valid).toBe(true)
})
it('requires CSRF for non-admin referers', () => {
const req = createMockRequest('POST', {
referer: 'https://test.example.com/some-other-page',
})
const result = validateCsrf(req)
expect(result.valid).toBe(false)
expect(result.reason).toContain('CSRF token missing')
})
})
describe('Double Submit Cookie Pattern', () => {
it('validates matching header and cookie tokens', () => {
const token = generateCsrfToken()

View file

@ -13,6 +13,7 @@ import {
isSensitiveField,
safeStringify,
createSafeLogger,
maskSmtpError,
} from '@/lib/security/data-masking'
describe('Data Masking', () => {
@ -389,6 +390,96 @@ describe('Data Masking', () => {
})
})
describe('maskSmtpError', () => {
it('masks SMTP authentication failure with credentials', () => {
const error = 'AUTH LOGIN failed: Username and Password not accepted.'
const masked = maskSmtpError(error)
expect(masked).toContain('[')
expect(masked).not.toContain('Username and Password')
})
it('masks 535 authentication error codes with details', () => {
const error = '535 5.7.8 Error: Username and Password not accepted for user@example.com'
const masked = maskSmtpError(error)
expect(masked).not.toContain('user@example.com')
expect(masked).toContain('535')
})
it('masks invalid login errors with user details', () => {
const error = 'Invalid login: 535-5.7.8 Username and Password not accepted'
const masked = maskSmtpError(error)
expect(masked).toContain('[')
})
it('masks connection strings in SMTP errors', () => {
const error = 'Connection failed to smtp://admin:secret123@mail.example.com:587'
const masked = maskSmtpError(error)
expect(masked).not.toContain('secret123')
expect(masked).toContain('[REDACTED]')
})
it('preserves safe SMTP error codes', () => {
const error = '530 5.7.0 Must issue a STARTTLS command first'
const masked = maskSmtpError(error)
// Should keep the diagnostic error code
expect(masked).toContain('530')
expect(masked).toContain('STARTTLS')
})
it('truncates excessively long error messages', () => {
const longError = 'Error: ' + 'A'.repeat(500)
const masked = maskSmtpError(longError)
expect(masked!.length).toBeLessThanOrEqual(203) // 200 + "..."
expect(masked).toContain('...')
})
it('handles null and undefined gracefully', () => {
expect(maskSmtpError(null)).toBeNull()
expect(maskSmtpError(undefined)).toBeNull()
})
it('handles empty string', () => {
expect(maskSmtpError('')).toBeNull()
})
it('passes through simple error messages unchanged', () => {
const error = 'Connection timeout'
const masked = maskSmtpError(error)
expect(masked).toBe('Connection timeout')
})
it('masks Base64 encoded credentials in AUTH', () => {
const error = 'AUTH PLAIN dXNlcm5hbWU6cGFzc3dvcmQxMjM= failed'
const masked = maskSmtpError(error)
expect(masked).not.toContain('dXNlcm5hbWU6cGFzc3dvcmQxMjM=')
})
it('masks password= patterns in SMTP error details', () => {
const error = 'SMTP config: host=mail.example.com password=supersecret123'
const masked = maskSmtpError(error)
expect(masked).not.toContain('supersecret123')
expect(masked).toContain('[REDACTED]')
})
})
describe('Real-world Scenarios', () => {
it('masks SMTP configuration', () => {
const smtpConfig = {

View file

@ -11,5 +11,27 @@ export default defineConfig({
'tests/int/**/*.int.spec.ts',
'tests/unit/**/*.unit.spec.ts',
],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'html', 'lcov'],
reportsDirectory: './coverage',
include: [
'src/lib/**/*.ts',
'src/hooks/**/*.ts',
'src/app/**/api/**/route.ts',
],
exclude: [
'src/**/*.d.ts',
'src/**/payload-types.ts',
'node_modules/**',
],
// Initial thresholds - increase as test coverage improves
thresholds: {
lines: 35,
functions: 50,
branches: 65,
statements: 35,
},
},
},
})