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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue