mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
f2c8444050
commit
405ab5f038
5 changed files with 1051 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
652
packages/frontend/src/admin/AdminSslPage.tsx
Normal file
652
packages/frontend/src/admin/AdminSslPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<Route path="external-links" element={<AdminExternalLinksPage />} />
|
||||
<Route path="customize" element={<AdminCustomizePage />} />
|
||||
<Route path="events" element={<AdminEventsPage />} />
|
||||
<Route path="ssl" element={<AdminSslPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue