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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 17:13:49 +01:00
parent f2c8444050
commit 405ab5f038
5 changed files with 1051 additions and 1 deletions

View file

@ -251,8 +251,14 @@ services:
# Rate Limiting # Rate Limiting
THROTTLE_TTL: ${THROTTLE_TTL:-60000} THROTTLE_TTL: ${THROTTLE_TTL:-60000}
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200} 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: volumes:
- ./keys:/app/keys:ro - ./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: networks:
- insight-web - insight-web
- insight-db - insight-db

View file

@ -2,6 +2,7 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Delete,
Body, Body,
Query, Query,
Logger, Logger,
@ -11,7 +12,10 @@ import {
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { RolesGuard } from '../../common/guards/roles.guard'; 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'; import { RedisService } from '../../redis/redis.service';
/** /**
@ -28,6 +32,41 @@ interface ExternalLink {
const EXTERNAL_LINKS_KEY = 'platform_external_links'; const EXTERNAL_LINKS_KEY = 'platform_external_links';
const BRANDING_LOGO_KEY = 'platform_branding_logo'; 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') @ApiTags('Settings')
@Controller('settings') @Controller('settings')
@ -295,4 +334,354 @@ export class SettingsController {
} }
return `${origin}/${bestHref}`; 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<SslConfig> {
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<DnsCheckResponse> {
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<string[]>((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<void> {
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<void> {
await this.safeUnlink(join(this.dynamicConfigPath, 'ssl-domain.yml'));
}
/**
* Loescht eine Datei ohne Fehler wenn sie nicht existiert.
*/
private async safeUnlink(filePath: string): Promise<void> {
try {
await access(filePath);
await unlink(filePath);
} catch {
// Datei existiert nicht — ok
}
}
} }

View file

@ -7,6 +7,7 @@ const tabs = [
{ to: '/admin/external-links', label: 'Externe Links' }, { to: '/admin/external-links', label: 'Externe Links' },
{ to: '/admin/customize', label: 'Anpassungen' }, { to: '/admin/customize', label: 'Anpassungen' },
{ to: '/admin/events', label: 'Events' }, { to: '/admin/events', label: 'Events' },
{ to: '/admin/ssl', label: 'SSL / Domain' },
]; ];
export function AdminLayout() { export function AdminLayout() {

View file

@ -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<SslConfig>({
queryKey: ['settings', 'ssl'],
queryFn: async () => {
const res = await api.get<SslConfig>('/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<DnsCheckResponse | null>(null);
const [dnsChecking, setDnsChecking] = useState(false);
// Feedback
const [warnings, setWarnings] = useState<string[]>([]);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState('');
// File input refs
const certInputRef = useRef<HTMLInputElement>(null);
const keyInputRef = useRef<HTMLInputElement>(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<DnsCheckResponse>('/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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<SslSaveResponse>('/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 <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>Laden...</p>;
}
const hasCert = !!config?.certificate;
const isActive = !!config?.enabled;
const certExpDays = config?.certificate?.validTo ? daysUntil(config.certificate.validTo) : null;
return (
<div>
{/* Status Banner */}
{hasCert && (
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>
Aktueller Status
</h3>
{isActive ? (
<span style={badgeStyle('#d1fae5', '#065f46')}>Aktiv</span>
) : (
<span style={badgeStyle('#f3f4f6', '#6b7280')}>Inaktiv</span>
)}
</div>
<div style={infoGridStyle}>
<span style={{ color: 'var(--color-text-muted)' }}>Domain</span>
<span style={{ fontWeight: 500 }}>
{config?.dnsName ? (
<a
href={`https://${config.dnsName}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)', textDecoration: 'none' }}
>
{config.dnsName}
</a>
) : (
'—'
)}
</span>
<span style={{ color: 'var(--color-text-muted)' }}>Zertifikat</span>
<span>{config?.certificate?.subject || '—'}</span>
<span style={{ color: 'var(--color-text-muted)' }}>Aussteller</span>
<span>{config?.certificate?.issuer || '—'}</span>
<span style={{ color: 'var(--color-text-muted)' }}>Gueltig bis</span>
<span>
{config?.certificate?.validTo ? (
<>
{formatDate(config.certificate.validTo)}
{certExpDays !== null && certExpDays <= 30 && certExpDays > 0 && (
<span style={badgeStyle('#fef3c7', '#92400e')}>
{' '}Noch {certExpDays} Tage
</span>
)}
{config.certificate.isExpired && (
<span style={badgeStyle('#fee2e2', '#991b1b')}>
{' '}Abgelaufen
</span>
)}
</>
) : (
'—'
)}
</span>
<span style={{ color: 'var(--color-text-muted)' }}>SANs</span>
<span style={{ fontSize: '0.8125rem' }}>
{config?.certificate?.sanList?.join(', ') || '—'}
</span>
<span style={{ color: 'var(--color-text-muted)' }}>Fingerprint</span>
<span style={{ fontSize: '0.75rem', fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>
{config?.certificate?.fingerprint || '—'}
</span>
{config?.lastUpdated && (
<>
<span style={{ color: 'var(--color-text-muted)' }}>Zuletzt aktualisiert</span>
<span>{formatDate(config.lastUpdated)}</span>
</>
)}
</div>
{isActive && (
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--color-border)' }}>
<button
style={btnDangerStyle}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Entfernen...' : 'SSL deaktivieren'}
</button>
</div>
)}
</div>
)}
{/* Feedback Messages */}
{saveSuccess && (
<div
style={{
padding: '0.75rem 1rem',
background: '#d1fae5',
border: '1px solid #a7f3d0',
borderRadius: 'var(--radius-sm)',
color: '#065f46',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
SSL-Konfiguration erfolgreich gespeichert.
</div>
)}
{saveError && (
<div
style={{
padding: '0.75rem 1rem',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-error)',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
{saveError}
</div>
)}
{warnings.length > 0 && (
<div
style={{
padding: '0.75rem 1rem',
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 'var(--radius-sm)',
color: '#92400e',
fontSize: '0.875rem',
marginBottom: '1rem',
}}
>
<strong>Hinweise:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
{/* DNS Name Section */}
<div style={cardStyle}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>
1. DNS-Name konfigurieren
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: '1rem' }}>
Erstellen Sie einen DNS A-Record, der auf die Server-IP zeigt.
Tragen Sie anschliessend den Domainnamen hier ein.
</p>
<div style={{ marginBottom: '0.75rem' }}>
<label style={labelStyle}>DNS-Name</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
style={{ ...inputStyle, flex: 1 }}
value={dnsName}
onChange={(e) => {
setDnsName(e.target.value);
setDnsCheck(null);
}}
placeholder="insight.firma.de"
/>
<button
style={btnOutlineStyle}
onClick={handleDnsCheck}
disabled={!dnsName.trim() || dnsChecking}
>
{dnsChecking ? 'Pruefe...' : 'DNS pruefen'}
</button>
</div>
</div>
{dnsCheck && (
<div
style={{
padding: '0.5rem 0.75rem',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
background: dnsCheck.resolves ? '#d1fae5' : '#fef3c7',
color: dnsCheck.resolves ? '#065f46' : '#92400e',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<span style={{ fontSize: '1rem' }}>{dnsCheck.resolves ? '\u2713' : '\u26A0'}</span>
{dnsCheck.resolves ? (
<span>
Aufgeloest: {dnsCheck.addresses.join(', ')}
</span>
) : (
<span>
Nicht aufgeloest. {dnsCheck.error || 'Bitte DNS A-Record pruefen.'}
</span>
)}
</div>
)}
</div>
{/* Certificate Upload Section */}
<div style={cardStyle}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>
2. SSL-Zertifikat hochladen
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: '1rem' }}>
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.'}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
{/* Cert Upload */}
<div>
<label style={labelStyle}>
Zertifikat (.pem, .crt, .cer) {!hasCert && '*'}
</label>
<input
ref={certInputRef}
type="file"
accept=".pem,.crt,.cer"
style={{ display: 'none' }}
onChange={handleCertFile}
/>
<div
style={{
...uploadZoneStyle,
borderColor: certPem ? 'var(--color-primary)' : undefined,
color: certPem ? 'var(--color-primary)' : undefined,
}}
onClick={() => certInputRef.current?.click()}
>
{certPem ? (
<>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ verticalAlign: 'middle', marginRight: '0.375rem' }}>
<path d="M5 8l2 2 4-4" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="8" cy="8" r="6" />
</svg>
{certFileName}
</>
) : (
'Zertifikat-Datei auswaehlen'
)}
</div>
</div>
{/* Key Upload */}
<div>
<label style={labelStyle}>
Private Key (.pem, .key) {!hasCert && '*'}
</label>
<input
ref={keyInputRef}
type="file"
accept=".pem,.key"
style={{ display: 'none' }}
onChange={handleKeyFile}
/>
<div
style={{
...uploadZoneStyle,
borderColor: keyPem ? 'var(--color-primary)' : undefined,
color: keyPem ? 'var(--color-primary)' : undefined,
}}
onClick={() => keyInputRef.current?.click()}
>
{keyPem ? (
<>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ verticalAlign: 'middle', marginRight: '0.375rem' }}>
<path d="M5 8l2 2 4-4" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="8" cy="8" r="6" />
</svg>
{keyFileName}
</>
) : (
'Key-Datei auswaehlen'
)}
</div>
</div>
</div>
</div>
{/* Save Button */}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '2rem' }}>
<button
style={{
...btnPrimaryStyle,
opacity: saveMutation.isPending ? 0.7 : 1,
cursor: saveMutation.isPending ? 'wait' : 'pointer',
}}
onClick={handleSave}
disabled={saveMutation.isPending || !dnsName.trim()}
>
{saveMutation.isPending
? 'Speichern...'
: isActive
? 'Aktualisieren'
: 'Speichern + Aktivieren'}
</button>
</div>
{/* Info Box */}
<div
style={{
padding: '1rem 1.25rem',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
lineHeight: 1.6,
}}
>
<strong style={{ color: 'var(--color-text)' }}>Hinweise:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}>
<li>Das Zertifikat muss im PEM-Format vorliegen (Base64-kodiert, beginnt mit <code>-----BEGIN CERTIFICATE-----</code>).</li>
<li>Der Private Key darf <strong>nicht</strong> passwortgeschuetzt sein.</li>
<li>Nach dem Speichern wird Traefik automatisch neu geladen (kein Neustart noetig).</li>
<li>Die bestehende IP-basierte Erreichbarkeit bleibt erhalten.</li>
<li>
<strong>CORS:</strong> Nach Aktivierung muessen die Umgebungsvariablen{' '}
<code>CORS_ORIGINS</code>, <code>APP_URL</code> und <code>FRONTEND_URL</code>{' '}
in der <code>.env</code> um die neue Domain ergaenzt werden.
</li>
</ul>
</div>
</div>
);
}

View file

@ -11,6 +11,7 @@ import { AdminSsoPage } from '../admin/AdminSsoPage';
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage'; import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
import { AdminCustomizePage } from '../admin/AdminCustomizePage'; import { AdminCustomizePage } from '../admin/AdminCustomizePage';
import { AdminEventsPage } from '../admin/AdminEventsPage'; import { AdminEventsPage } from '../admin/AdminEventsPage';
import { AdminSslPage } from '../admin/AdminSslPage';
import { ProfilePage } from '../profile/ProfilePage'; import { ProfilePage } from '../profile/ProfilePage';
import { ContactsPage } from '../crm/contacts/ContactsPage'; import { ContactsPage } from '../crm/contacts/ContactsPage';
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage'; import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
@ -80,6 +81,7 @@ export function App() {
<Route path="external-links" element={<AdminExternalLinksPage />} /> <Route path="external-links" element={<AdminExternalLinksPage />} />
<Route path="customize" element={<AdminCustomizePage />} /> <Route path="customize" element={<AdminCustomizePage />} />
<Route path="events" element={<AdminEventsPage />} /> <Route path="events" element={<AdminEventsPage />} />
<Route path="ssl" element={<AdminSslPage />} />
</Route> </Route>
</Route> </Route>