From 405ab5f0389bd410e90899f291f16d5cdb5de5ca Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 17:13:49 +0100 Subject: [PATCH] feat: add SSL/Domain admin page for custom HTTPS configuration - Backend: 4 new endpoints in SettingsController (GET/POST/DELETE /settings/ssl, POST /settings/ssl/check-dns) - Certificate validation via Node.js crypto.X509Certificate (PEM format, expiry, SAN match) - DNS resolution check via dns.promises.resolve4 - Auto-generates Traefik dynamic config (ssl-domain.yml) with custom domain routing + HTTP->HTTPS redirect - Frontend: AdminSslPage with DNS name input, cert/key upload, status display - Docker: Core-service gets access to traefik-certs volume and dynamic config directory Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 6 + .../src/core/settings/settings.controller.ts | 391 ++++++++++- packages/frontend/src/admin/AdminLayout.tsx | 1 + packages/frontend/src/admin/AdminSslPage.tsx | 652 ++++++++++++++++++ packages/frontend/src/shell/App.tsx | 2 + 5 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/admin/AdminSslPage.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 6444cf0..6cdc979 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -251,8 +251,14 @@ services: # Rate Limiting THROTTLE_TTL: ${THROTTLE_TTL:-60000} THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200} + # SSL-Admin: Pfade fuer Traefik-Zertifikate und Dynamic Config + TRAEFIK_CERTS_PATH: /app/traefik-certs + TRAEFIK_DYNAMIC_PATH: /app/traefik-dynamic volumes: - ./keys:/app/keys:ro + # SSL-Admin: Core-Service schreibt Cert-Dateien und Traefik Dynamic Config + - traefik-certs:/app/traefik-certs + - ./config/traefik/dynamic:/app/traefik-dynamic networks: - insight-web - insight-db diff --git a/packages/core-service/src/core/settings/settings.controller.ts b/packages/core-service/src/core/settings/settings.controller.ts index 84e8ebf..ed2ada8 100644 --- a/packages/core-service/src/core/settings/settings.controller.ts +++ b/packages/core-service/src/core/settings/settings.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, + Delete, Body, Query, Logger, @@ -11,7 +12,10 @@ import { import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { Roles } from '../../common/decorators/roles.decorator'; import { RolesGuard } from '../../common/guards/roles.guard'; -import { randomUUID } from 'crypto'; +import { randomUUID, X509Certificate, createPrivateKey } from 'crypto'; +import { promises as dns } from 'dns'; +import { writeFile, readFile, unlink, access } from 'fs/promises'; +import { join } from 'path'; import { RedisService } from '../../redis/redis.service'; /** @@ -28,6 +32,41 @@ interface ExternalLink { const EXTERNAL_LINKS_KEY = 'platform_external_links'; const BRANDING_LOGO_KEY = 'platform_branding_logo'; +const SSL_CONFIG_KEY = 'platform_ssl_config'; + +/** + * SSL/Domain-Konfiguration — Metadata in Redis, Cert-Dateien auf Filesystem. + */ +interface SslCertInfo { + subject: string; + issuer: string; + validFrom: string; + validTo: string; + sanList: string[]; + dnsMatch: boolean; + isExpired: boolean; + fingerprint: string; +} + +interface SslConfig { + enabled: boolean; + dnsName: string | null; + certificate: SslCertInfo | null; + lastUpdated: string | null; +} + +interface SslSaveRequest { + dnsName: string; + certificate: string; + privateKey: string; + enabled: boolean; +} + +interface DnsCheckResponse { + resolves: boolean; + addresses: string[]; + error?: string; +} @ApiTags('Settings') @Controller('settings') @@ -295,4 +334,354 @@ export class SettingsController { } return `${origin}/${bestHref}`; } + + // ============================================================ + // SSL / Domain Configuration + // ============================================================ + + private get certsPath(): string { + return process.env.TRAEFIK_CERTS_PATH || '/app/traefik-certs'; + } + + private get dynamicConfigPath(): string { + return process.env.TRAEFIK_DYNAMIC_PATH || '/app/traefik-dynamic'; + } + + /** + * GET /api/v1/settings/ssl + * Aktuelle SSL/Domain-Konfiguration lesen. + */ + @Get('ssl') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'SSL/Domain-Konfiguration lesen' }) + async getSslConfig(): Promise { + const raw = await this.redis.get(SSL_CONFIG_KEY); + if (!raw) { + return { enabled: false, dnsName: null, certificate: null, lastUpdated: null }; + } + + try { + return JSON.parse(raw) as SslConfig; + } catch { + return { enabled: false, dnsName: null, certificate: null, lastUpdated: null }; + } + } + + /** + * POST /api/v1/settings/ssl + * SSL-Konfiguration speichern: DNS-Name + Zertifikat + Key. + * Schreibt Cert-Dateien und generiert Traefik Dynamic Config. + */ + @Post('ssl') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'SSL/Domain-Konfiguration speichern (Admin)' }) + async saveSslConfig( + @Body() body: SslSaveRequest, + ): Promise<{ success: boolean; config: SslConfig; warnings: string[] }> { + const warnings: string[] = []; + + // 1. DNS-Name validieren + const dnsName = body.dnsName?.trim(); + if (!dnsName) { + throw new BadRequestException('DNS-Name ist erforderlich'); + } + if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/.test(dnsName)) { + throw new BadRequestException('Ungueltiger DNS-Name. Beispiel: insight.firma.de'); + } + + // 2. DNS-Aufloesung pruefen (Warnung, kein Fehler) + try { + await dns.resolve4(dnsName); + } catch { + warnings.push(`DNS-Name "${dnsName}" konnte nicht aufgeloest werden. Stellen Sie sicher, dass ein A-Record auf den Server zeigt.`); + } + + // 3. PEM-Zertifikat — Sentinel __KEEP_EXISTING__ erlaubt Update ohne neuen Cert-Upload + const keepExisting = body.certificate?.trim() === '__KEEP_EXISTING__' && body.privateKey?.trim() === '__KEEP_EXISTING__'; + let certPem: string | undefined; + let keyPem: string | undefined; + + if (keepExisting) { + // Bestehende Dateien verwenden — pruefen ob sie existieren + try { + certPem = await readFile(join(this.certsPath, 'custom.crt'), 'utf-8'); + keyPem = await readFile(join(this.certsPath, 'custom.key'), 'utf-8'); + } catch { + throw new BadRequestException('Kein bestehendes Zertifikat vorhanden. Bitte Zertifikat und Key hochladen.'); + } + } else { + // Neues Zertifikat validieren + certPem = body.certificate?.trim(); + if (!certPem) { + throw new BadRequestException('SSL-Zertifikat ist erforderlich'); + } + // Base64 Data-URL decodieren falls noetig + if (certPem.startsWith('data:')) { + const base64Part = certPem.split(',')[1]; + if (base64Part) { + certPem = Buffer.from(base64Part, 'base64').toString('utf-8'); + } + } + if (!certPem.includes('-----BEGIN CERTIFICATE-----')) { + throw new BadRequestException('Ungueltiges Zertifikat-Format. PEM-Datei mit -----BEGIN CERTIFICATE----- erwartet.'); + } + + // Private Key validieren + keyPem = body.privateKey?.trim(); + if (!keyPem) { + throw new BadRequestException('Private Key ist erforderlich'); + } + if (keyPem.startsWith('data:')) { + const base64Part = keyPem.split(',')[1]; + if (base64Part) { + keyPem = Buffer.from(base64Part, 'base64').toString('utf-8'); + } + } + if (!keyPem.includes('-----BEGIN') || !keyPem.includes('PRIVATE KEY-----')) { + throw new BadRequestException('Ungueltiges Key-Format. PEM-Datei mit -----BEGIN PRIVATE KEY----- oder -----BEGIN RSA PRIVATE KEY----- erwartet.'); + } + } + + // 5. Zertifikat parsen + let certInfo: SslCertInfo; + try { + const x509 = new X509Certificate(certPem); + const sanRaw = x509.subjectAltName || ''; + const sanList = sanRaw + .split(',') + .map((s: string) => s.trim().replace(/^DNS:/, '')) + .filter((s: string) => s.length > 0); + + const now = new Date(); + const validFrom = new Date(x509.validFrom); + const validTo = new Date(x509.validTo); + const isExpired = now > validTo; + + // SAN-Match pruefen + const dnsMatch = sanList.some((san: string) => { + if (san.startsWith('*.')) { + // Wildcard: *.firma.de matches sub.firma.de + const domain = san.slice(2); + return dnsName.endsWith(domain) && dnsName.split('.').length === san.split('.').length; + } + return san.toLowerCase() === dnsName.toLowerCase(); + }); + + certInfo = { + subject: x509.subject, + issuer: x509.issuer, + validFrom: validFrom.toISOString(), + validTo: validTo.toISOString(), + sanList, + dnsMatch, + isExpired, + fingerprint: x509.fingerprint256, + }; + + if (isExpired) { + throw new BadRequestException( + `Zertifikat ist abgelaufen (gueltig bis ${validTo.toLocaleDateString('de-DE')})`, + ); + } + + if (!dnsMatch) { + warnings.push( + `DNS-Name "${dnsName}" ist nicht im Zertifikat enthalten. SANs: ${sanList.join(', ') || 'keine'}`, + ); + } + } catch (err) { + if (err instanceof BadRequestException) throw err; + throw new BadRequestException(`Zertifikat konnte nicht gelesen werden: ${(err as Error).message}`); + } + + // 6. Key-Validierung (kann als privater Schluessel geladen werden?) + try { + createPrivateKey(keyPem); + } catch { + throw new BadRequestException('Private Key konnte nicht gelesen werden. Stellen Sie sicher, dass der Key im PEM-Format vorliegt.'); + } + + // 7. Cert + Key Dateien schreiben + try { + await writeFile(join(this.certsPath, 'custom.crt'), certPem, 'utf-8'); + await writeFile(join(this.certsPath, 'custom.key'), keyPem, { encoding: 'utf-8', mode: 0o600 }); + this.logger.log('SSL-Zertifikat und Key geschrieben'); + } catch (err) { + throw new BadRequestException(`Zertifikat-Dateien konnten nicht geschrieben werden: ${(err as Error).message}`); + } + + // 8. Traefik Dynamic Config generieren (wenn enabled) + if (body.enabled) { + await this.writeTraefikSslConfig(dnsName); + this.logger.log(`Traefik SSL-Config fuer ${dnsName} generiert`); + } else { + await this.removeTraefikSslConfig(); + this.logger.log('Traefik SSL-Config deaktiviert'); + } + + // 9. Metadata in Redis speichern + const config: SslConfig = { + enabled: body.enabled, + dnsName, + certificate: certInfo, + lastUpdated: new Date().toISOString(), + }; + await this.redis.set(SSL_CONFIG_KEY, JSON.stringify(config)); + + this.logger.log(`SSL-Konfiguration gespeichert: ${dnsName}, enabled=${body.enabled}`); + + return { success: true, config, warnings }; + } + + /** + * POST /api/v1/settings/ssl/check-dns + * DNS-Aufloesung fuer einen Domainnamen pruefen. + */ + @Post('ssl/check-dns') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'DNS-Aufloesung pruefen' }) + async checkDns( + @Body() body: { dnsName: string }, + ): Promise { + const dnsName = body.dnsName?.trim(); + if (!dnsName) { + throw new BadRequestException('dnsName ist erforderlich'); + } + + try { + const resolver = new dns.Resolver(); + resolver.setServers(['8.8.8.8', '1.1.1.1']); + + const addresses = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('DNS-Timeout (5s)')), 5000); + dns.resolve4(dnsName) + .then((addrs) => { clearTimeout(timeout); resolve(addrs); }) + .catch((err) => { clearTimeout(timeout); reject(err); }); + }); + + return { resolves: true, addresses }; + } catch (err) { + return { + resolves: false, + addresses: [], + error: (err as Error).message || 'DNS-Aufloesung fehlgeschlagen', + }; + } + } + + /** + * DELETE /api/v1/settings/ssl + * SSL deaktivieren und aufraumen. + */ + @Delete('ssl') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'SSL/Domain-Konfiguration entfernen' }) + async deleteSslConfig(): Promise<{ success: boolean }> { + // Traefik Dynamic Config entfernen + await this.removeTraefikSslConfig(); + + // Cert-Dateien entfernen + await this.safeUnlink(join(this.certsPath, 'custom.crt')); + await this.safeUnlink(join(this.certsPath, 'custom.key')); + + // Redis-Key entfernen + await this.redis.del(SSL_CONFIG_KEY); + + this.logger.log('SSL-Konfiguration vollstaendig entfernt'); + + return { success: true }; + } + + /** + * Generiert die Traefik Dynamic Config fuer Custom-Domain-Routing. + * Traefik erkennt Datei-Aenderungen automatisch (file.watch=true). + */ + private async writeTraefikSslConfig(dnsName: string): Promise { + const escapedDns = dnsName.replace(/`/g, ''); + const yaml = `# Auto-generated by INSIGHT Platform — SSL/Domain Configuration +# DO NOT EDIT MANUALLY — Changes will be overwritten by the admin panel. +# Generated: ${new Date().toISOString()} + +tls: + certificates: + - certFile: /certs/custom.crt + keyFile: /certs/custom.key + +http: + routers: + # HTTPS Frontend (Custom Domain) + custom-frontend-secure: + rule: "Host(\`${escapedDns}\`)" + entrypoints: + - websecure + service: frontend@docker + tls: {} + priority: 10 + + # HTTPS API (Custom Domain) + custom-api-secure: + rule: "Host(\`${escapedDns}\`) && PathPrefix(\`/api\`)" + entrypoints: + - websecure + service: core-api@docker + tls: {} + priority: 110 + middlewares: + - cors-api@file + - security-headers@file + + # HTTPS CRM API (Custom Domain) + custom-crm-secure: + rule: "Host(\`${escapedDns}\`) && PathPrefix(\`/api/v1/crm\`)" + entrypoints: + - websecure + service: crm@docker + tls: {} + priority: 120 + middlewares: + - cors-api@file + - security-headers@file + + # HTTP -> HTTPS Redirect (Custom Domain) + custom-redirect: + rule: "Host(\`${escapedDns}\`)" + entrypoints: + - web + middlewares: + - custom-https-redirect + service: frontend@docker + priority: 10 + + middlewares: + custom-https-redirect: + redirectScheme: + scheme: https + permanent: true +`; + + await writeFile(join(this.dynamicConfigPath, 'ssl-domain.yml'), yaml, 'utf-8'); + } + + /** + * Entfernt die Traefik SSL Dynamic Config. + */ + private async removeTraefikSslConfig(): Promise { + await this.safeUnlink(join(this.dynamicConfigPath, 'ssl-domain.yml')); + } + + /** + * Loescht eine Datei ohne Fehler wenn sie nicht existiert. + */ + private async safeUnlink(filePath: string): Promise { + try { + await access(filePath); + await unlink(filePath); + } catch { + // Datei existiert nicht — ok + } + } } diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx index a5f92b5..ca1c7f5 100644 --- a/packages/frontend/src/admin/AdminLayout.tsx +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -7,6 +7,7 @@ const tabs = [ { to: '/admin/external-links', label: 'Externe Links' }, { to: '/admin/customize', label: 'Anpassungen' }, { to: '/admin/events', label: 'Events' }, + { to: '/admin/ssl', label: 'SSL / Domain' }, ]; export function AdminLayout() { diff --git a/packages/frontend/src/admin/AdminSslPage.tsx b/packages/frontend/src/admin/AdminSslPage.tsx new file mode 100644 index 0000000..201ea90 --- /dev/null +++ b/packages/frontend/src/admin/AdminSslPage.tsx @@ -0,0 +1,652 @@ +import { useState, useEffect, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; + +// ============================================================ +// Types +// ============================================================ + +interface SslCertInfo { + subject: string; + issuer: string; + validFrom: string; + validTo: string; + sanList: string[]; + dnsMatch: boolean; + isExpired: boolean; + fingerprint: string; +} + +interface SslConfig { + enabled: boolean; + dnsName: string | null; + certificate: SslCertInfo | null; + lastUpdated: string | null; +} + +interface DnsCheckResponse { + resolves: boolean; + addresses: string[]; + error?: string; +} + +interface SslSaveResponse { + success: boolean; + config: SslConfig; + warnings: string[]; +} + +// ============================================================ +// Styles +// ============================================================ + +const cardStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-sm)', + border: '1px solid var(--color-border)', + padding: '1.5rem', + marginBottom: '1.5rem', +}; + +const labelStyle: React.CSSProperties = { + fontSize: '0.875rem', + fontWeight: 500, + color: 'var(--color-text)', + marginBottom: '0.375rem', + display: 'block', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.625rem 0.75rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.9375rem', + outline: 'none', + boxSizing: 'border-box', + background: 'var(--color-bg-card)', + color: 'var(--color-text)', +}; + +const btnPrimaryStyle: React.CSSProperties = { + padding: '0.5rem 1rem', + background: 'var(--color-primary)', + color: 'white', + border: 'none', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', +}; + +const btnOutlineStyle: React.CSSProperties = { + padding: '0.5rem 1rem', + background: 'transparent', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + cursor: 'pointer', + color: 'var(--color-text-secondary)', +}; + +const btnDangerStyle: React.CSSProperties = { + padding: '0.5rem 1rem', + background: 'transparent', + border: '1px solid #fecaca', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + cursor: 'pointer', + color: 'var(--color-error)', +}; + +const badgeStyle = (bg: string, color: string): React.CSSProperties => ({ + display: 'inline-block', + padding: '0.125rem 0.5rem', + borderRadius: '9999px', + fontSize: '0.75rem', + fontWeight: 500, + background: bg, + color, +}); + +const uploadZoneStyle: React.CSSProperties = { + border: '2px dashed var(--color-border)', + borderRadius: 'var(--radius-sm)', + padding: '1rem', + textAlign: 'center', + cursor: 'pointer', + transition: 'border-color 0.15s', + background: 'var(--color-bg)', + fontSize: '0.875rem', + color: 'var(--color-text-muted)', +}; + +const infoGridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: '160px 1fr', + gap: '0.5rem 1rem', + fontSize: '0.875rem', +}; + +// ============================================================ +// Helpers +// ============================================================ + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function daysUntil(iso: string): number { + const diff = new Date(iso).getTime() - Date.now(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +// ============================================================ +// Component +// ============================================================ + +export function AdminSslPage() { + const queryClient = useQueryClient(); + + // Current config from backend + const { data: config, isLoading } = useQuery({ + queryKey: ['settings', 'ssl'], + queryFn: async () => { + const res = await api.get('/settings/ssl'); + return res.data; + }, + }); + + // Form state + const [dnsName, setDnsName] = useState(''); + const [certPem, setCertPem] = useState(''); + const [keyPem, setKeyPem] = useState(''); + const [certFileName, setCertFileName] = useState(''); + const [keyFileName, setKeyFileName] = useState(''); + + // DNS check state + const [dnsCheck, setDnsCheck] = useState(null); + const [dnsChecking, setDnsChecking] = useState(false); + + // Feedback + const [warnings, setWarnings] = useState([]); + const [saveSuccess, setSaveSuccess] = useState(false); + const [saveError, setSaveError] = useState(''); + + // File input refs + const certInputRef = useRef(null); + const keyInputRef = useRef(null); + + // Init from existing config + useEffect(() => { + if (config?.dnsName) { + setDnsName(config.dnsName); + } + }, [config]); + + // ---- DNS Check ---- + const handleDnsCheck = async () => { + if (!dnsName.trim()) return; + setDnsChecking(true); + setDnsCheck(null); + try { + const res = await api.post('/settings/ssl/check-dns', { + dnsName: dnsName.trim(), + }); + setDnsCheck(res.data); + } catch { + setDnsCheck({ resolves: false, addresses: [], error: 'Pruefung fehlgeschlagen' }); + } finally { + setDnsChecking(false); + } + }; + + // ---- File Handlers ---- + const handleCertFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setCertFileName(file.name); + const reader = new FileReader(); + reader.onload = () => setCertPem(reader.result as string); + reader.readAsText(file); + }; + + const handleKeyFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setKeyFileName(file.name); + const reader = new FileReader(); + reader.onload = () => setKeyPem(reader.result as string); + reader.readAsText(file); + }; + + // ---- Save Mutation ---- + const saveMutation = useMutation({ + mutationFn: async (data: { dnsName: string; certificate: string; privateKey: string; enabled: boolean }) => { + const res = await api.post('/settings/ssl', data); + return res.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['settings', 'ssl'] }); + setWarnings(data.warnings); + setSaveSuccess(true); + setSaveError(''); + setCertPem(''); + setKeyPem(''); + setCertFileName(''); + setKeyFileName(''); + setTimeout(() => setSaveSuccess(false), 5000); + }, + onError: (err: unknown) => { + const msg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? 'Fehler beim Speichern'; + setSaveError(msg); + setSaveSuccess(false); + }, + }); + + // ---- Delete Mutation ---- + const deleteMutation = useMutation({ + mutationFn: async () => { + await api.delete('/settings/ssl'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', 'ssl'] }); + setDnsName(''); + setCertPem(''); + setKeyPem(''); + setCertFileName(''); + setKeyFileName(''); + setWarnings([]); + setDnsCheck(null); + setSaveSuccess(false); + setSaveError(''); + }, + }); + + const handleSave = () => { + setSaveError(''); + setWarnings([]); + + if (!dnsName.trim()) { + setSaveError('DNS-Name ist erforderlich'); + return; + } + + // Wenn ein bestehendes Cert existiert und keine neuen Dateien hochgeladen → nur DNS-Name aendern + // Aber PEM-Dateien sind noetig bei Ersteinrichtung + if (!certPem && !config?.certificate) { + setSaveError('SSL-Zertifikat ist erforderlich'); + return; + } + if (!keyPem && !config?.certificate) { + setSaveError('Private Key ist erforderlich'); + return; + } + + saveMutation.mutate({ + dnsName: dnsName.trim(), + certificate: certPem || '__KEEP_EXISTING__', + privateKey: keyPem || '__KEEP_EXISTING__', + enabled: true, + }); + }; + + const handleDelete = () => { + if (!window.confirm('SSL-Konfiguration wirklich entfernen? Die Plattform ist danach nur noch ueber HTTP / IP erreichbar.')) { + return; + } + deleteMutation.mutate(); + }; + + // ---- Render ---- + + if (isLoading) { + return

Laden...

; + } + + const hasCert = !!config?.certificate; + const isActive = !!config?.enabled; + const certExpDays = config?.certificate?.validTo ? daysUntil(config.certificate.validTo) : null; + + return ( +
+ {/* Status Banner */} + {hasCert && ( +
+
+

+ Aktueller Status +

+ {isActive ? ( + Aktiv + ) : ( + Inaktiv + )} +
+ +
+ Domain + + {config?.dnsName ? ( + + {config.dnsName} + + ) : ( + '—' + )} + + + Zertifikat + {config?.certificate?.subject || '—'} + + Aussteller + {config?.certificate?.issuer || '—'} + + Gueltig bis + + {config?.certificate?.validTo ? ( + <> + {formatDate(config.certificate.validTo)} + {certExpDays !== null && certExpDays <= 30 && certExpDays > 0 && ( + + {' '}Noch {certExpDays} Tage + + )} + {config.certificate.isExpired && ( + + {' '}Abgelaufen + + )} + + ) : ( + '—' + )} + + + SANs + + {config?.certificate?.sanList?.join(', ') || '—'} + + + Fingerprint + + {config?.certificate?.fingerprint || '—'} + + + {config?.lastUpdated && ( + <> + Zuletzt aktualisiert + {formatDate(config.lastUpdated)} + + )} +
+ + {isActive && ( +
+ +
+ )} +
+ )} + + {/* Feedback Messages */} + {saveSuccess && ( +
+ SSL-Konfiguration erfolgreich gespeichert. +
+ )} + + {saveError && ( +
+ {saveError} +
+ )} + + {warnings.length > 0 && ( +
+ Hinweise: +
    + {warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} + + {/* DNS Name Section */} +
+

+ 1. DNS-Name konfigurieren +

+

+ Erstellen Sie einen DNS A-Record, der auf die Server-IP zeigt. + Tragen Sie anschliessend den Domainnamen hier ein. +

+ +
+ +
+ { + setDnsName(e.target.value); + setDnsCheck(null); + }} + placeholder="insight.firma.de" + /> + +
+
+ + {dnsCheck && ( +
+ {dnsCheck.resolves ? '\u2713' : '\u26A0'} + {dnsCheck.resolves ? ( + + Aufgeloest: {dnsCheck.addresses.join(', ')} + + ) : ( + + Nicht aufgeloest. {dnsCheck.error || 'Bitte DNS A-Record pruefen.'} + + )} +
+ )} +
+ + {/* Certificate Upload Section */} +
+

+ 2. SSL-Zertifikat hochladen +

+

+ Laden Sie Ihr SSL-Zertifikat und den zugehoerigen Private Key im PEM-Format hoch. + {hasCert && ' Lassen Sie die Felder leer, um das bestehende Zertifikat zu behalten.'} +

+ +
+ {/* Cert Upload */} +
+ + +
certInputRef.current?.click()} + > + {certPem ? ( + <> + + + + + {certFileName} + + ) : ( + 'Zertifikat-Datei auswaehlen' + )} +
+
+ + {/* Key Upload */} +
+ + +
keyInputRef.current?.click()} + > + {keyPem ? ( + <> + + + + + {keyFileName} + + ) : ( + 'Key-Datei auswaehlen' + )} +
+
+
+
+ + {/* Save Button */} +
+ +
+ + {/* Info Box */} +
+ Hinweise: +
    +
  • Das Zertifikat muss im PEM-Format vorliegen (Base64-kodiert, beginnt mit -----BEGIN CERTIFICATE-----).
  • +
  • Der Private Key darf nicht passwortgeschuetzt sein.
  • +
  • Nach dem Speichern wird Traefik automatisch neu geladen (kein Neustart noetig).
  • +
  • Die bestehende IP-basierte Erreichbarkeit bleibt erhalten.
  • +
  • + CORS: Nach Aktivierung muessen die Umgebungsvariablen{' '} + CORS_ORIGINS, APP_URL und FRONTEND_URL{' '} + in der .env um die neue Domain ergaenzt werden. +
  • +
+
+
+ ); +} diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index dce2a43..e5c76b2 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -11,6 +11,7 @@ import { AdminSsoPage } from '../admin/AdminSsoPage'; import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage'; import { AdminCustomizePage } from '../admin/AdminCustomizePage'; import { AdminEventsPage } from '../admin/AdminEventsPage'; +import { AdminSslPage } from '../admin/AdminSslPage'; import { ProfilePage } from '../profile/ProfilePage'; import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; @@ -80,6 +81,7 @@ export function App() { } /> } /> } /> + } />