mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
feat: admin UX improvements with tenant switcher and email config
Tenant-Wechsel UI: - Add TenantBreadcrumb component showing active tenant in admin header - Add German translations for multi-tenant plugin selector - Integrate with existing plugin TenantSelector dropdown Email-Konfiguration UX: - Add SMTP field validation (host format, port range, required fields) - Add EmailDeliverabilityInfo component with SPF/DKIM/DMARC guidance - Add TestEmailButton component for SMTP configuration testing - Create /api/test-email endpoint with full security: - CSRF protection (double-submit cookie) - IP allowlist (same rules as /api/send-email) - Rate limiting (10/min per user) - Tenant access control with proper object normalization Security: - Add comprehensive integration tests for /api/test-email - Tests cover CSRF, IP blocking, auth, tenant access, input validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b5f319bc99
commit
53f26e7349
12 changed files with 1766 additions and 10 deletions
|
|
@ -308,13 +308,14 @@
|
|||
- [ ] Staging-Deployment
|
||||
|
||||
#### Admin UX
|
||||
- [ ] **Tenant-Wechsel UI**
|
||||
- [ ] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel
|
||||
- [ ] Tenant-Kontext in Breadcrumbs anzeigen
|
||||
- [ ] **Email-Konfiguration UX**
|
||||
- [ ] Formularvalidierung für SMTP-Settings
|
||||
- [ ] Tooltips für SPF/DKIM-Hinweise
|
||||
- [ ] "Test-Email senden" Button
|
||||
- [x] **Tenant-Wechsel UI** (Erledigt: 08.12.2025)
|
||||
- [x] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel (Multi-Tenant Plugin integriert)
|
||||
- [x] Tenant-Kontext in Breadcrumbs anzeigen (Custom TenantBreadcrumb Komponente)
|
||||
- [x] Deutsche Übersetzungen für Tenant-Selector hinzugefügt
|
||||
- [x] **Email-Konfiguration UX** (Erledigt: 08.12.2025)
|
||||
- [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
|
||||
|
|
@ -437,4 +438,4 @@
|
|||
|
||||
---
|
||||
|
||||
*Letzte Aktualisierung: 08.12.2025 (Security Test Suite implementiert)*
|
||||
*Letzte Aktualisierung: 08.12.2025 (Email-Konfiguration UX implementiert)*
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import { EmailDeliverabilityInfo as EmailDeliverabilityInfo_2d3125262d0174dacf5e33938b9b89de } from '@/components/admin/EmailDeliverabilityInfo'
|
||||
import { TestEmailButton as TestEmailButton_99635bc14de407531576022cd79284db } from '@/components/admin/TestEmailButton'
|
||||
import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
|
|
@ -23,11 +25,14 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
|
|||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
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 { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@/components/admin/EmailDeliverabilityInfo#EmailDeliverabilityInfo": EmailDeliverabilityInfo_2d3125262d0174dacf5e33938b9b89de,
|
||||
"@/components/admin/TestEmailButton#TestEmailButton": TestEmailButton_99635bc14de407531576022cd79284db,
|
||||
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
|
|
@ -52,6 +57,7 @@ export const importMap = {
|
|||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/components/admin/TenantBreadcrumb#TenantBreadcrumb": TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
|
||||
}
|
||||
|
|
|
|||
223
src/app/(payload)/api/test-email/route.ts
Normal file
223
src/app/(payload)/api/test-email/route.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { sendTestEmail } from '@/lib/email/tenant-email-service'
|
||||
import {
|
||||
validateCsrf,
|
||||
createSafeLogger,
|
||||
emailLimiter,
|
||||
rateLimitHeaders,
|
||||
validateIpAccess,
|
||||
} from '@/lib/security'
|
||||
|
||||
const RATE_LIMIT_MAX = 10
|
||||
const logger = createSafeLogger('API:TestEmail')
|
||||
|
||||
interface UserWithTenants {
|
||||
id: number
|
||||
email?: string
|
||||
isSuperAdmin?: boolean
|
||||
tenants?: Array<{
|
||||
tenant: { id: number } | number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Zugriff auf den angegebenen Tenant hat
|
||||
* Normalisiert tenant-Objekte korrekt (können { id: number } oder number sein)
|
||||
*/
|
||||
function userHasAccessToTenant(user: UserWithTenants, tenantId: number): boolean {
|
||||
// Super Admins haben Zugriff auf alle Tenants
|
||||
if (user.isSuperAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Prüfe ob User dem Tenant zugeordnet ist
|
||||
if (!user.tenants || user.tenants.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.tenants.some((t) => {
|
||||
// Normalisiere: tenant kann ein Objekt { id: number } oder direkt eine number sein
|
||||
const userTenantId = typeof t.tenant === 'object' && t.tenant !== null
|
||||
? t.tenant.id
|
||||
: t.tenant
|
||||
return userTenantId === tenantId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/test-email
|
||||
*
|
||||
* Sendet eine Test-E-Mail um die SMTP-Konfiguration eines Tenants zu verifizieren.
|
||||
*
|
||||
* Request Body:
|
||||
* - tenantId: number | string - ID des Tenants
|
||||
* - recipientEmail: string - E-Mail-Adresse des Empfängers
|
||||
*
|
||||
* Security:
|
||||
* - IP-Allowlist (optional via SEND_EMAIL_ALLOWED_IPS env)
|
||||
* - CSRF-Schutz (Double Submit Cookie)
|
||||
* - Authentifizierung erforderlich
|
||||
* - Tenant-Zugriffskontrolle
|
||||
* - Rate-Limiting (10/min pro User)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// IP-Allowlist prüfen (falls konfiguriert) - gleiche Regeln wie /api/send-email
|
||||
const ipCheck = validateIpAccess(req, 'sendEmail')
|
||||
if (!ipCheck.allowed) {
|
||||
logger.warn(`IP blocked: ${ipCheck.ip}`, { reason: ipCheck.reason })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Access denied' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
// CSRF-Schutz für Browser-basierte Requests
|
||||
const csrfResult = validateCsrf(req)
|
||||
if (!csrfResult.valid) {
|
||||
logger.warn('CSRF validation failed', { reason: csrfResult.reason })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'CSRF validation failed' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
// User aus Session prüfen
|
||||
const { user } = await payload.auth({ headers: req.headers })
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Nicht authentifiziert' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
const typedUser = user as UserWithTenants
|
||||
|
||||
// Rate Limiting prüfen
|
||||
const rateLimit = await emailLimiter.check(String(typedUser.id))
|
||||
if (!rateLimit.allowed) {
|
||||
logger.warn('Rate limit exceeded', { userId: typedUser.id })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Rate limit exceeded',
|
||||
message: `Maximum ${RATE_LIMIT_MAX} Test-E-Mails pro Minute. Versuchen Sie es in ${rateLimit.retryAfter || 60} Sekunden erneut.`,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders(rateLimit, RATE_LIMIT_MAX),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Request Body parsen
|
||||
const body = await req.json()
|
||||
const { tenantId, recipientEmail } = body
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'tenantId erforderlich' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const numericTenantId = Number(tenantId)
|
||||
if (isNaN(numericTenantId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'tenantId muss eine Zahl sein' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!recipientEmail) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'recipientEmail erforderlich' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// E-Mail-Format validieren
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(recipientEmail)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Ungültiges E-Mail-Format' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Zugriffskontrolle: User muss Zugriff auf den Tenant haben
|
||||
if (!userHasAccessToTenant(typedUser, numericTenantId)) {
|
||||
logger.warn('Access denied to tenant', {
|
||||
userId: typedUser.id,
|
||||
tenantId: numericTenantId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Kein Zugriff auf diesen Tenant' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
// Tenant prüfen - existiert er?
|
||||
const tenant = await payload.findByID({
|
||||
collection: 'tenants',
|
||||
id: numericTenantId,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Tenant nicht gefunden' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
// Rate Limit Headers
|
||||
const rlHeaders = rateLimitHeaders(rateLimit, RATE_LIMIT_MAX)
|
||||
|
||||
// Test-E-Mail senden
|
||||
const result = await sendTestEmail(payload, numericTenantId, recipientEmail)
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Test email sent successfully', {
|
||||
tenantId: numericTenantId,
|
||||
recipient: recipientEmail,
|
||||
messageId: result.messageId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: `Test-E-Mail erfolgreich an ${recipientEmail} gesendet`,
|
||||
messageId: result.messageId,
|
||||
logId: result.logId,
|
||||
},
|
||||
{ headers: rlHeaders },
|
||||
)
|
||||
} else {
|
||||
logger.error('Test email failed', {
|
||||
tenantId: numericTenantId,
|
||||
error: result.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error || 'Fehler beim Senden der Test-E-Mail',
|
||||
},
|
||||
{ status: 500, headers: rlHeaders },
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('test-email error', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Interner Server-Fehler',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,39 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import type { CollectionConfig, FieldHook } from 'payload'
|
||||
import { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache'
|
||||
import { auditTenantAfterChange, auditTenantAfterDelete } from '../hooks/auditTenantChanges'
|
||||
|
||||
/**
|
||||
* Validiert SMTP Host Format
|
||||
*/
|
||||
const validateSmtpHost: FieldHook = ({ value, siblingData }) => {
|
||||
// Nur validieren wenn useCustomSmtp aktiv ist und ein Wert vorhanden
|
||||
const emailData = siblingData as { useCustomSmtp?: boolean }
|
||||
if (!emailData?.useCustomSmtp) return value
|
||||
if (!value) return value
|
||||
|
||||
// Basis-Validierung: Keine Protokoll-Präfixe erlaubt
|
||||
if (value.includes('://')) {
|
||||
throw new Error('SMTP Host ohne Protokoll angeben (z.B. smtp.example.com)')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert SMTP Port
|
||||
*/
|
||||
const validateSmtpPort: FieldHook = ({ value, siblingData }) => {
|
||||
const emailData = siblingData as { useCustomSmtp?: boolean }
|
||||
if (!emailData?.useCustomSmtp) return value
|
||||
|
||||
const port = Number(value)
|
||||
if (port && (port < 1 || port > 65535)) {
|
||||
throw new Error('Port muss zwischen 1 und 65535 liegen')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: 'tenants',
|
||||
admin: {
|
||||
|
|
@ -53,6 +85,8 @@ export const Tenants: CollectionConfig = {
|
|||
admin: {
|
||||
placeholder: 'info@domain.de',
|
||||
width: '50%',
|
||||
description:
|
||||
'Tipp: Verwenden Sie eine E-Mail-Adresse der Domain, für die SPF/DKIM konfiguriert ist.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -74,11 +108,25 @@ export const Tenants: CollectionConfig = {
|
|||
placeholder: 'kontakt@domain.de (optional)',
|
||||
},
|
||||
},
|
||||
// SPF/DKIM Info-Block
|
||||
{
|
||||
name: 'emailDeliverabilityInfo',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@/components/admin/EmailDeliverabilityInfo#EmailDeliverabilityInfo',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'useCustomSmtp',
|
||||
type: 'checkbox',
|
||||
label: 'Eigenen SMTP-Server verwenden',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'Aktivieren Sie diese Option, um einen eigenen SMTP-Server statt der globalen Einstellungen zu verwenden.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtp',
|
||||
|
|
@ -86,6 +134,8 @@ export const Tenants: CollectionConfig = {
|
|||
label: 'SMTP Einstellungen',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.useCustomSmtp,
|
||||
description:
|
||||
'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.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -95,9 +145,21 @@ export const Tenants: CollectionConfig = {
|
|||
name: 'host',
|
||||
type: 'text',
|
||||
label: 'SMTP Host',
|
||||
required: true,
|
||||
admin: {
|
||||
placeholder: 'smtp.example.com',
|
||||
width: '50%',
|
||||
description: 'Hostname ohne Protokoll (z.B. smtp.gmail.com)',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [validateSmtpHost],
|
||||
},
|
||||
validate: (value, { siblingData }) => {
|
||||
const emailData = siblingData as { useCustomSmtp?: boolean }
|
||||
if (emailData?.useCustomSmtp && !value) {
|
||||
return 'SMTP Host ist erforderlich'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -107,15 +169,22 @@ export const Tenants: CollectionConfig = {
|
|||
defaultValue: 587,
|
||||
admin: {
|
||||
width: '25%',
|
||||
description: '587 (STARTTLS) oder 465 (SSL)',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [validateSmtpPort],
|
||||
},
|
||||
min: 1,
|
||||
max: 65535,
|
||||
},
|
||||
{
|
||||
name: 'secure',
|
||||
type: 'checkbox',
|
||||
label: 'SSL/TLS',
|
||||
label: 'SSL/TLS (Port 465)',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
width: '25%',
|
||||
description: 'Für Port 465 aktivieren',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -127,8 +196,18 @@ export const Tenants: CollectionConfig = {
|
|||
name: 'user',
|
||||
type: 'text',
|
||||
label: 'SMTP Benutzername',
|
||||
required: true,
|
||||
admin: {
|
||||
width: '50%',
|
||||
description: 'Meist die E-Mail-Adresse',
|
||||
},
|
||||
validate: (value, { siblingData }) => {
|
||||
const smtpData = siblingData as { host?: string }
|
||||
// Nur validieren wenn host gesetzt ist (d.h. SMTP aktiv)
|
||||
if (smtpData?.host && !value) {
|
||||
return 'SMTP Benutzername ist erforderlich'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -137,6 +216,7 @@ export const Tenants: CollectionConfig = {
|
|||
label: 'SMTP Passwort',
|
||||
admin: {
|
||||
width: '50%',
|
||||
description: 'Leer lassen um bestehendes Passwort zu behalten',
|
||||
},
|
||||
access: {
|
||||
read: () => false, // Passwort nie in API-Response
|
||||
|
|
@ -155,6 +235,16 @@ export const Tenants: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
// Test-Email Button
|
||||
{
|
||||
name: 'testEmailButton',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@/components/admin/TestEmailButton#TestEmailButton',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
140
src/components/admin/EmailDeliverabilityInfo.scss
Normal file
140
src/components/admin/EmailDeliverabilityInfo.scss
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
.email-deliverability-info {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--theme-elevation-150);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
background-color: var(--theme-elevation-50);
|
||||
|
||||
&__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-elevation-800);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--theme-elevation-500);
|
||||
|
||||
&--expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid var(--theme-elevation-150);
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-top: 1rem;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 0.75rem !important;
|
||||
font-style: italic;
|
||||
color: var(--theme-elevation-500) !important;
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
background-color: var(--theme-elevation-100);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__tips {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed var(--theme-elevation-200);
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--theme-success-500);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
:global(.dark) .email-deliverability-info {
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-color: var(--theme-elevation-200);
|
||||
|
||||
&__content {
|
||||
border-top-color: var(--theme-elevation-200);
|
||||
}
|
||||
|
||||
&__code {
|
||||
background-color: var(--theme-elevation-150);
|
||||
}
|
||||
|
||||
&__tips {
|
||||
border-top-color: var(--theme-elevation-250);
|
||||
}
|
||||
}
|
||||
106
src/components/admin/EmailDeliverabilityInfo.tsx
Normal file
106
src/components/admin/EmailDeliverabilityInfo.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import './EmailDeliverabilityInfo.scss'
|
||||
|
||||
/**
|
||||
* Info-Komponente mit SPF/DKIM Hinweisen für E-Mail-Zustellbarkeit
|
||||
*/
|
||||
export const EmailDeliverabilityInfo: React.FC = () => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="email-deliverability-info">
|
||||
<button
|
||||
type="button"
|
||||
className="email-deliverability-info__toggle"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<svg
|
||||
className={`email-deliverability-info__icon ${isExpanded ? 'email-deliverability-info__icon--expanded' : ''}`}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span className="email-deliverability-info__title">
|
||||
Hinweise zur E-Mail-Zustellbarkeit (SPF/DKIM/DMARC)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="email-deliverability-info__content">
|
||||
<div className="email-deliverability-info__section">
|
||||
<h4>SPF (Sender Policy Framework)</h4>
|
||||
<p>
|
||||
SPF definiert, welche Server E-Mails für Ihre Domain senden dürfen. Fügen Sie einen
|
||||
TXT-Eintrag in Ihren DNS-Einstellungen hinzu:
|
||||
</p>
|
||||
<code className="email-deliverability-info__code">
|
||||
v=spf1 include:_spf.ihremailserver.de ~all
|
||||
</code>
|
||||
<p className="email-deliverability-info__hint">
|
||||
Ersetzen Sie <em>_spf.ihremailserver.de</em> mit dem SPF-Include Ihres E-Mail-Providers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="email-deliverability-info__section">
|
||||
<h4>DKIM (DomainKeys Identified Mail)</h4>
|
||||
<p>
|
||||
DKIM signiert ausgehende E-Mails kryptografisch. Die Einrichtung erfolgt über Ihren
|
||||
E-Mail-Provider. Typischerweise erhalten Sie einen TXT-Eintrag wie:
|
||||
</p>
|
||||
<code className="email-deliverability-info__code">
|
||||
selector._domainkey.ihredomain.de → v=DKIM1; k=rsa; p=MIGf...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="email-deliverability-info__section">
|
||||
<h4>DMARC (Domain-based Message Authentication)</h4>
|
||||
<p>
|
||||
DMARC kombiniert SPF und DKIM und definiert, wie mit fehlgeschlagenen Prüfungen
|
||||
umgegangen werden soll:
|
||||
</p>
|
||||
<code className="email-deliverability-info__code">
|
||||
_dmarc.ihredomain.de → v=DMARC1; p=quarantine; rua=mailto:dmarc@ihredomain.de
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="email-deliverability-info__tips">
|
||||
<h4>Tipps für bessere Zustellbarkeit</h4>
|
||||
<ul>
|
||||
<li>Verwenden Sie immer eine Absender-Adresse der konfigurierten Domain</li>
|
||||
<li>Testen Sie die Konfiguration mit dem "Test-E-Mail senden" Button</li>
|
||||
<li>
|
||||
Prüfen Sie Ihre DNS-Einträge mit Tools wie{' '}
|
||||
<a
|
||||
href="https://mxtoolbox.com/spf.aspx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MXToolbox
|
||||
</a>{' '}
|
||||
oder{' '}
|
||||
<a
|
||||
href="https://www.mail-tester.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Mail-Tester
|
||||
</a>
|
||||
</li>
|
||||
<li>Bei Problemen kontaktieren Sie Ihren E-Mail-Provider für DKIM-Keys</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailDeliverabilityInfo
|
||||
37
src/components/admin/TenantBreadcrumb.scss
Normal file
37
src/components/admin/TenantBreadcrumb.scss
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
.tenant-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin: 0 0.75rem;
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
background-color: var(--theme-elevation-50);
|
||||
border: 1px solid var(--theme-elevation-100);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
&__label {
|
||||
color: var(--theme-elevation-500);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--theme-elevation-800);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
:global(.dark) .tenant-breadcrumb {
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-color: var(--theme-elevation-200);
|
||||
|
||||
.tenant-breadcrumb__label {
|
||||
color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
.tenant-breadcrumb__value {
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
}
|
||||
30
src/components/admin/TenantBreadcrumb.tsx
Normal file
30
src/components/admin/TenantBreadcrumb.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import './TenantBreadcrumb.scss'
|
||||
|
||||
/**
|
||||
* Custom Breadcrumb-Komponente die den aktuellen Tenant-Kontext anzeigt.
|
||||
* Wird in der Admin-Header-Leiste eingefügt.
|
||||
*/
|
||||
export const TenantBreadcrumb: React.FC = () => {
|
||||
const { selectedTenantID, options } = useTenantSelection()
|
||||
|
||||
// Finde den aktuellen Tenant aus den Optionen
|
||||
const currentTenant = options?.find((opt) => opt.value === selectedTenantID)
|
||||
|
||||
// Zeige nichts wenn kein Tenant ausgewählt oder nur ein Tenant verfügbar
|
||||
if (!selectedTenantID || !currentTenant || options.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tenant-breadcrumb">
|
||||
<span className="tenant-breadcrumb__label">Aktiver Tenant:</span>
|
||||
<span className="tenant-breadcrumb__value">{currentTenant.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TenantBreadcrumb
|
||||
173
src/components/admin/TestEmailButton.scss
Normal file
173
src/components/admin/TestEmailButton.scss
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
.test-email-button {
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--theme-elevation-150);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
background-color: var(--theme-elevation-50);
|
||||
|
||||
&__header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__input-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
|
||||
&__input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--theme-elevation-200);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
font-size: 0.875rem;
|
||||
background-color: var(--theme-input-background);
|
||||
color: var(--theme-elevation-800);
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-success-500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-elevation-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
background-color: var(--theme-success-500);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--theme-success-600);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background-color: var(--theme-elevation-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: test-email-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
&__result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&--success {
|
||||
background-color: rgba(var(--theme-success-500-rgb), 0.1);
|
||||
border: 1px solid var(--theme-success-500);
|
||||
color: var(--theme-success-600);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: rgba(var(--theme-error-500-rgb), 0.1);
|
||||
border: 1px solid var(--theme-error-500);
|
||||
color: var(--theme-error-600);
|
||||
}
|
||||
}
|
||||
|
||||
&__result-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
&__result-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__result-id {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
&__warning {
|
||||
margin: 0.75rem 0 0 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-warning-600);
|
||||
background-color: rgba(var(--theme-warning-500-rgb), 0.1);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes test-email-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
:global(.dark) .test-email-button {
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-color: var(--theme-elevation-200);
|
||||
|
||||
&__input {
|
||||
border-color: var(--theme-elevation-250);
|
||||
}
|
||||
}
|
||||
257
src/components/admin/TestEmailButton.tsx
Normal file
257
src/components/admin/TestEmailButton.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { useDocumentInfo, useAuth } from '@payloadcms/ui'
|
||||
import './TestEmailButton.scss'
|
||||
|
||||
type TestStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
interface TestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
// CSRF Constants (muss mit src/lib/security/csrf.ts übereinstimmen)
|
||||
const CSRF_COOKIE_NAME = 'csrf-token'
|
||||
const CSRF_HEADER_NAME = 'X-CSRF-Token'
|
||||
|
||||
/**
|
||||
* Liest einen Cookie-Wert
|
||||
*/
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
|
||||
return match ? decodeURIComponent(match[2]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein CSRF-Token vom Server und setzt es als Cookie
|
||||
*/
|
||||
async function fetchCsrfToken(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch('/api/csrf-token', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return data.token || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch CSRF token:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Button zum Senden einer Test-E-Mail für SMTP-Konfigurationstest
|
||||
* Implementiert CSRF-Schutz via Double Submit Cookie
|
||||
*/
|
||||
export const TestEmailButton: React.FC = () => {
|
||||
const { id: documentId } = useDocumentInfo()
|
||||
const { user } = useAuth()
|
||||
const [status, setStatus] = useState<TestStatus>('idle')
|
||||
const [result, setResult] = useState<TestResult | null>(null)
|
||||
const [recipientEmail, setRecipientEmail] = useState('')
|
||||
const [csrfToken, setCsrfToken] = useState<string | null>(null)
|
||||
|
||||
// Standardmäßig die E-Mail des aktuellen Users verwenden
|
||||
const defaultEmail = (user as { email?: string })?.email || ''
|
||||
|
||||
// CSRF-Token beim Mount laden
|
||||
useEffect(() => {
|
||||
const initCsrf = async () => {
|
||||
// Erst versuchen aus Cookie zu lesen
|
||||
let token = getCookie(CSRF_COOKIE_NAME)
|
||||
|
||||
// Falls kein Cookie vorhanden, Token vom Server holen
|
||||
if (!token) {
|
||||
token = await fetchCsrfToken()
|
||||
}
|
||||
|
||||
setCsrfToken(token)
|
||||
}
|
||||
|
||||
initCsrf()
|
||||
}, [])
|
||||
|
||||
const handleSendTest = useCallback(async () => {
|
||||
const emailToUse = recipientEmail || defaultEmail
|
||||
|
||||
if (!emailToUse) {
|
||||
setResult({ success: false, message: 'Bitte eine Empfänger-E-Mail angeben' })
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!documentId) {
|
||||
setResult({ success: false, message: 'Tenant muss erst gespeichert werden' })
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('loading')
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
// CSRF-Token aus State oder frisch aus Cookie lesen
|
||||
let token = csrfToken || getCookie(CSRF_COOKIE_NAME)
|
||||
|
||||
// Falls immer noch kein Token, neues holen
|
||||
if (!token) {
|
||||
token = await fetchCsrfToken()
|
||||
if (token) {
|
||||
setCsrfToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setResult({ success: false, message: 'CSRF-Token konnte nicht geladen werden. Bitte Seite neu laden.' })
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('/api/test-email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[CSRF_HEADER_NAME]: token,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
tenantId: documentId,
|
||||
recipientEmail: emailToUse,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
message: 'Test-E-Mail erfolgreich gesendet!',
|
||||
messageId: data.messageId,
|
||||
})
|
||||
setStatus('success')
|
||||
} else {
|
||||
// Bei CSRF-Fehler Token neu laden
|
||||
if (response.status === 403 && data.error?.includes('CSRF')) {
|
||||
const newToken = await fetchCsrfToken()
|
||||
if (newToken) {
|
||||
setCsrfToken(newToken)
|
||||
}
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Sicherheitstoken abgelaufen. Bitte erneut versuchen.',
|
||||
})
|
||||
} else {
|
||||
setResult({
|
||||
success: false,
|
||||
message: data.error || data.message || 'Fehler beim Senden der Test-E-Mail',
|
||||
})
|
||||
}
|
||||
setStatus('error')
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Netzwerkfehler',
|
||||
})
|
||||
setStatus('error')
|
||||
}
|
||||
}, [documentId, recipientEmail, defaultEmail, csrfToken])
|
||||
|
||||
return (
|
||||
<div className="test-email-button">
|
||||
<div className="test-email-button__header">
|
||||
<h4 className="test-email-button__title">SMTP-Konfiguration testen</h4>
|
||||
<p className="test-email-button__description">
|
||||
Senden Sie eine Test-E-Mail um zu prüfen, ob die SMTP-Einstellungen korrekt sind.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="test-email-button__form">
|
||||
<div className="test-email-button__input-group">
|
||||
<label htmlFor="test-email-recipient" className="test-email-button__label">
|
||||
Empfänger-E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="test-email-recipient"
|
||||
type="email"
|
||||
className="test-email-button__input"
|
||||
placeholder={defaultEmail || 'empfaenger@example.com'}
|
||||
value={recipientEmail}
|
||||
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`test-email-button__button test-email-button__button--${status}`}
|
||||
onClick={handleSendTest}
|
||||
disabled={status === 'loading' || !documentId}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<>
|
||||
<span className="test-email-button__spinner" />
|
||||
Sende...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="test-email-button__icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 2L11 13" />
|
||||
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
|
||||
</svg>
|
||||
Test-E-Mail senden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`test-email-button__result test-email-button__result--${result.success ? 'success' : 'error'}`}
|
||||
>
|
||||
<span className="test-email-button__result-icon">
|
||||
{result.success ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" 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>
|
||||
)}
|
||||
</span>
|
||||
<span className="test-email-button__result-message">
|
||||
{result.message}
|
||||
{result.messageId && (
|
||||
<small className="test-email-button__result-id">ID: {result.messageId}</small>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!documentId && (
|
||||
<p className="test-email-button__warning">
|
||||
Speichern Sie den Tenant zuerst, um eine Test-E-Mail senden zu können.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestEmailButton
|
||||
|
|
@ -58,6 +58,10 @@ export default buildConfig({
|
|||
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de',
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
components: {
|
||||
// Tenant-Kontext in der Admin-Header-Leiste anzeigen
|
||||
afterNavLinks: ['@/components/admin/TenantBreadcrumb#TenantBreadcrumb'],
|
||||
},
|
||||
},
|
||||
// Multi-Tenant Email Adapter
|
||||
email: multiTenantEmailAdapter,
|
||||
|
|
@ -165,6 +169,23 @@ export default buildConfig({
|
|||
// Super Admins haben Zugriff auf alle Tenants
|
||||
userHasAccessToAllTenants: (user) => Boolean(user?.isSuperAdmin),
|
||||
debug: true,
|
||||
// Deutsche Übersetzungen für den Tenant-Selector
|
||||
i18n: {
|
||||
translations: {
|
||||
de: {
|
||||
'nav-tenantSelector-label': 'Nach Tenant filtern',
|
||||
'assign-tenant-button-label': 'Tenant zuweisen',
|
||||
'assign-tenant-modal-title': '"{{title}}" zuweisen',
|
||||
'field-assignedTenant-label': 'Zugewiesener Tenant',
|
||||
},
|
||||
en: {
|
||||
'nav-tenantSelector-label': 'Filter by Tenant',
|
||||
'assign-tenant-button-label': 'Assign Tenant',
|
||||
'assign-tenant-modal-title': 'Assign "{{title}}"',
|
||||
'field-assignedTenant-label': 'Assigned Tenant',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
seoPlugin({
|
||||
collections: [],
|
||||
|
|
|
|||
|
|
@ -515,4 +515,676 @@ describe('Security API Integration', () => {
|
|||
expect(response.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Test Email Endpoint Security Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Test Email Endpoint (/api/test-email)', () => {
|
||||
describe('CSRF Protection', () => {
|
||||
it('requires CSRF token for browser requests', async () => {
|
||||
vi.resetModules()
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('CSRF')
|
||||
})
|
||||
|
||||
it('accepts valid CSRF token', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
// Mock authenticated user
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'admin@test.com',
|
||||
isSuperAdmin: true,
|
||||
tenants: [],
|
||||
},
|
||||
}),
|
||||
findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
// Should pass CSRF check (may fail on email send, but not 403)
|
||||
expect(response.status).not.toBe(403)
|
||||
})
|
||||
|
||||
it('rejects expired CSRF tokens', async () => {
|
||||
vi.resetModules()
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const expiredToken = generateExpiredCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': expiredToken,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': expiredToken,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('IP Blocking', () => {
|
||||
it('blocks requests from blocked IPs', async () => {
|
||||
vi.stubEnv('BLOCKED_IPS', '192.168.200.100')
|
||||
vi.resetModules()
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: '192.168.200.100',
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('Access denied')
|
||||
})
|
||||
|
||||
it('respects SEND_EMAIL_ALLOWED_IPS allowlist', async () => {
|
||||
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '10.0.0.1')
|
||||
vi.resetModules()
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
// Request from IP not in allowlist
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: '192.168.1.100', // Not in allowlist
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('Access denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('requires authentication', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
// Mock unauthenticated user
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({ user: null }),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('authentifiziert')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tenant Access Control', () => {
|
||||
it('allows super admin access to any tenant', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'superadmin@test.com',
|
||||
isSuperAdmin: true,
|
||||
tenants: [],
|
||||
},
|
||||
}),
|
||||
findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Any Tenant' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock sendTestEmail
|
||||
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
||||
sendTestEmail: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
messageId: 'test-123',
|
||||
logId: 1,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 99,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
// Super admin should have access
|
||||
expect(response.status).not.toBe(403)
|
||||
})
|
||||
|
||||
it('denies access to tenants user is not assigned to', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 2,
|
||||
email: 'user@test.com',
|
||||
isSuperAdmin: false,
|
||||
tenants: [{ tenant: { id: 1 } }], // Only has access to tenant 1
|
||||
},
|
||||
}),
|
||||
findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Other Tenant' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 99, // Different tenant
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('Zugriff')
|
||||
})
|
||||
|
||||
it('normalizes tenant object format { tenant: { id } }', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 2,
|
||||
email: 'user@test.com',
|
||||
isSuperAdmin: false,
|
||||
// Multi-tenant plugin format: { tenant: { id: number } }
|
||||
tenants: [{ tenant: { id: 5 } }],
|
||||
},
|
||||
}),
|
||||
findByID: vi.fn().mockResolvedValue({ id: 5, name: 'User Tenant' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
||||
sendTestEmail: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
messageId: 'test-456',
|
||||
logId: 2,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 5,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
// Should have access (tenant ID 5 matches)
|
||||
expect(response.status).not.toBe(403)
|
||||
})
|
||||
|
||||
it('normalizes tenant primitive format { tenant: number }', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 2,
|
||||
email: 'user@test.com',
|
||||
isSuperAdmin: false,
|
||||
// Alternative format: { tenant: number }
|
||||
tenants: [{ tenant: 7 }],
|
||||
},
|
||||
}),
|
||||
findByID: vi.fn().mockResolvedValue({ id: 7, name: 'User Tenant' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
||||
sendTestEmail: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
messageId: 'test-789',
|
||||
logId: 3,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 7,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
// Should have access (tenant ID 7 matches)
|
||||
expect(response.status).not.toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('requires tenantId', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, isSuperAdmin: true },
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
recipientEmail: 'test@example.com',
|
||||
// Missing tenantId
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('tenantId')
|
||||
})
|
||||
|
||||
it('requires recipientEmail', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, isSuperAdmin: true },
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
// Missing recipientEmail
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('recipientEmail')
|
||||
})
|
||||
|
||||
it('validates email format', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, isSuperAdmin: true },
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'invalid-email', // Invalid format
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('E-Mail')
|
||||
})
|
||||
|
||||
it('validates tenantId is numeric', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, isSuperAdmin: true },
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 'not-a-number',
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('Zahl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('includes rate limit headers', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('payload', () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
auth: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, isSuperAdmin: true },
|
||||
}),
|
||||
findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
||||
sendTestEmail: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
messageId: 'test-rate',
|
||||
logId: 1,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
||||
|
||||
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
||||
const req = createMockRequest({
|
||||
method: 'POST',
|
||||
url: 'https://test.example.com/api/test-email',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://test.example.com',
|
||||
'X-CSRF-Token': token,
|
||||
},
|
||||
cookies: {
|
||||
'csrf-token': token,
|
||||
},
|
||||
ip: randomIp(),
|
||||
})
|
||||
|
||||
Object.defineProperty(req, 'json', {
|
||||
value: vi.fn().mockResolvedValue({
|
||||
tenantId: 1,
|
||||
recipientEmail: 'test@example.com',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
// Should include rate limit headers on success
|
||||
if (response.status === 200) {
|
||||
expect(response.headers.has('X-RateLimit-Limit')).toBe(true)
|
||||
expect(response.headers.has('X-RateLimit-Remaining')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue