impressum datenschutz added
This commit is contained in:
parent
fd55742c57
commit
a15772cb54
|
|
@ -421,6 +421,50 @@ export async function updateAuthorizationTemplate(req: AuthRequest, res: Respons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== IMPRESSUM & WEBSITE-DATENSCHUTZ ====================
|
||||||
|
|
||||||
|
export async function getImprint(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const html = await appSettingService.getSetting('imprintHtml');
|
||||||
|
res.json({ success: true, data: { html: html || '' } });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateImprint(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { html } = req.body;
|
||||||
|
if (typeof html !== 'string') return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
|
||||||
|
await appSettingService.setSetting('imprintHtml', html);
|
||||||
|
await logChange({ req, action: 'UPDATE', resourceType: 'AppSetting', label: 'Impressum aktualisiert' });
|
||||||
|
res.json({ success: true, message: 'Impressum gespeichert' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebsitePrivacyPolicy(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const html = await appSettingService.getSetting('websitePrivacyPolicyHtml');
|
||||||
|
res.json({ success: true, data: { html: html || '' } });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWebsitePrivacyPolicy(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { html } = req.body;
|
||||||
|
if (typeof html !== 'string') return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
|
||||||
|
await appSettingService.setSetting('websitePrivacyPolicyHtml', html);
|
||||||
|
await logChange({ req, action: 'UPDATE', resourceType: 'AppSetting', label: 'Website-Datenschutzerklärung aktualisiert' });
|
||||||
|
res.json({ success: true, message: 'Website-Datenschutzerklärung gespeichert' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== SEND CONSENT LINK ====================
|
// ==================== SEND CONSENT LINK ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@ router.put('/privacy-policy', requirePermission('gdpr:admin'), gdprController.up
|
||||||
router.get('/authorization-template', requirePermission('gdpr:admin'), gdprController.getAuthorizationTemplate);
|
router.get('/authorization-template', requirePermission('gdpr:admin'), gdprController.getAuthorizationTemplate);
|
||||||
router.put('/authorization-template', requirePermission('gdpr:admin'), gdprController.updateAuthorizationTemplate);
|
router.put('/authorization-template', requirePermission('gdpr:admin'), gdprController.updateAuthorizationTemplate);
|
||||||
|
|
||||||
|
// Impressum (Editor + Portal-Anzeige)
|
||||||
|
router.get('/imprint', gdprController.getImprint);
|
||||||
|
router.put('/imprint', requirePermission('gdpr:admin'), gdprController.updateImprint);
|
||||||
|
|
||||||
|
// Website-Datenschutzerklärung (Editor + Portal-Anzeige)
|
||||||
|
router.get('/website-privacy-policy', gdprController.getWebsitePrivacyPolicy);
|
||||||
|
router.put('/website-privacy-policy', requirePermission('gdpr:admin'), gdprController.updateWebsitePrivacyPolicy);
|
||||||
|
|
||||||
// Consent-Link senden
|
// Consent-Link senden
|
||||||
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
|
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ Alle Datumsfelder mit 0 davor wenn es ne einstellige Zahl ist
|
||||||
Jetzt : 1.1.2026
|
Jetzt : 1.1.2026
|
||||||
Und gewollt 01.01.2026
|
Und gewollt 01.01.2026
|
||||||
|
|
||||||
|
#erledigt
|
||||||
Die Auditmeldungen aussagekräftig
|
Die Auditmeldungen aussagekräftig
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ import ConsentPage from './pages/public/ConsentPage';
|
||||||
import PrivacyPolicyEditor from './pages/settings/PrivacyPolicyEditor';
|
import PrivacyPolicyEditor from './pages/settings/PrivacyPolicyEditor';
|
||||||
import PortalPrivacy from './pages/portal/PortalPrivacy';
|
import PortalPrivacy from './pages/portal/PortalPrivacy';
|
||||||
import AuthorizationTemplateEditor from './pages/settings/AuthorizationTemplateEditor';
|
import AuthorizationTemplateEditor from './pages/settings/AuthorizationTemplateEditor';
|
||||||
|
import ImprintEditor from './pages/settings/ImprintEditor';
|
||||||
|
import WebsitePrivacyPolicyEditor from './pages/settings/WebsitePrivacyPolicyEditor';
|
||||||
|
import PortalImprint from './pages/portal/PortalImprint';
|
||||||
|
import PortalWebsitePrivacy from './pages/portal/PortalWebsitePrivacy';
|
||||||
import PortalAuthorizations from './pages/portal/PortalAuthorizations';
|
import PortalAuthorizations from './pages/portal/PortalAuthorizations';
|
||||||
import PortalProfile from './pages/portal/PortalProfile';
|
import PortalProfile from './pages/portal/PortalProfile';
|
||||||
import PortalMeters from './pages/portal/PortalMeters';
|
import PortalMeters from './pages/portal/PortalMeters';
|
||||||
|
|
@ -170,6 +174,8 @@ function App() {
|
||||||
<Route path="my-profile" element={<PortalProfile />} />
|
<Route path="my-profile" element={<PortalProfile />} />
|
||||||
<Route path="my-meters" element={<PortalMeters />} />
|
<Route path="my-meters" element={<PortalMeters />} />
|
||||||
<Route path="privacy" element={<PortalPrivacy />} />
|
<Route path="privacy" element={<PortalPrivacy />} />
|
||||||
|
<Route path="imprint" element={<PortalImprint />} />
|
||||||
|
<Route path="website-privacy" element={<PortalWebsitePrivacy />} />
|
||||||
<Route path="authorizations" element={<PortalAuthorizations />} />
|
<Route path="authorizations" element={<PortalAuthorizations />} />
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
|
|
@ -190,6 +196,8 @@ function App() {
|
||||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
||||||
|
<Route path="settings/imprint" element={<ImprintEditor />} />
|
||||||
|
<Route path="settings/website-privacy-policy" element={<WebsitePrivacyPolicyEditor />} />
|
||||||
|
|
||||||
{/* Redirect old users route */}
|
{/* Redirect old users route */}
|
||||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useAuth } from '../../context/AuthContext';
|
||||||
import { gdprApi } from '../../services/api';
|
import { gdprApi } from '../../services/api';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import ScrollToTopButton from '../ScrollToTopButton';
|
import ScrollToTopButton from '../ScrollToTopButton';
|
||||||
import { AlertTriangle, ArrowRight } from 'lucide-react';
|
import { AlertTriangle, ArrowRight, Building, Shield } from 'lucide-react';
|
||||||
|
|
||||||
function ConsentBanner() {
|
function ConsentBanner() {
|
||||||
const { user, isCustomerPortal } = useAuth();
|
const { user, isCustomerPortal } = useAuth();
|
||||||
|
|
@ -41,6 +41,27 @@ function ConsentBanner() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PortalFooter() {
|
||||||
|
const { isCustomerPortal } = useAuth();
|
||||||
|
if (!isCustomerPortal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t bg-gray-50 px-8 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-6 text-sm text-gray-500">
|
||||||
|
<Link to="/imprint" className="flex items-center gap-1 hover:text-blue-600 transition-colors">
|
||||||
|
<Building className="w-3 h-3" />
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<Link to="/website-privacy" className="flex items-center gap-1 hover:text-blue-600 transition-colors">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
Datenschutzerklärung
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
|
|
@ -50,6 +71,7 @@ export default function Layout() {
|
||||||
<main className="flex-1 p-8">
|
<main className="flex-1 p-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<PortalFooter />
|
||||||
</div>
|
</div>
|
||||||
<ScrollToTopButton />
|
<ScrollToTopButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,44 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('gdpr:admin') && (
|
||||||
|
<Link
|
||||||
|
to="/settings/imprint"
|
||||||
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||||
|
<FileEdit className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||||
|
Impressum
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Impressum für das Kundenportal bearbeiten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{hasPermission('gdpr:admin') && (
|
||||||
|
<Link
|
||||||
|
to="/settings/website-privacy-policy"
|
||||||
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||||
|
<FileEdit className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||||
|
Website-Datenschutzerklärung
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Datenschutzerklärung für das Kundenportal bearbeiten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { gdprApi } from '../../services/api';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import { Building } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function PortalImprint() {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['imprint'],
|
||||||
|
queryFn: () => gdprApi.getImprint(),
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||||||
|
if (isError) return <div className="text-center py-8 text-red-500">Impressum konnte nicht geladen werden. Bitte Server neu starten.</div>;
|
||||||
|
|
||||||
|
const html = data?.data?.html || '<p class="text-gray-500">Noch kein Impressum hinterlegt.</p>';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Building className="w-6 h-6 text-blue-600" />
|
||||||
|
<h1 className="text-2xl font-bold">Impressum</h1>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { gdprApi } from '../../services/api';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function PortalWebsitePrivacy() {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['website-privacy-policy'],
|
||||||
|
queryFn: () => gdprApi.getWebsitePrivacyPolicy(),
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||||||
|
if (isError) return <div className="text-center py-8 text-red-500">Datenschutzerklärung konnte nicht geladen werden. Bitte Server neu starten.</div>;
|
||||||
|
|
||||||
|
const html = data?.data?.html || '<p class="text-gray-500">Noch keine Datenschutzerklärung hinterlegt.</p>';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Shield className="w-6 h-6 text-blue-600" />
|
||||||
|
<h1 className="text-2xl font-bold">Datenschutzerklärung</h1>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import TiptapLink from '@tiptap/extension-link';
|
||||||
|
import { gdprApi } from '../../services/api';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import { ArrowLeft, Save, Eye, Bold, Italic, List, ListOrdered, Heading1, Heading2, Heading3, Link as LinkIcon, Undo, Redo, Type } from 'lucide-react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
const DEFAULT_IMPRINT = `<h1>Impressum</h1>
|
||||||
|
|
||||||
|
<h2>Angaben gemäß § 5 TMG</h2>
|
||||||
|
|
||||||
|
<p>Hacker-Net Telekommunikation<br>
|
||||||
|
Stefan Hacker<br>
|
||||||
|
Am Wunderburgpark 5b<br>
|
||||||
|
26135 Oldenburg</p>
|
||||||
|
|
||||||
|
<h2>Kontakt</h2>
|
||||||
|
|
||||||
|
<p>Telefon: [Ihre Telefonnummer]<br>
|
||||||
|
E-Mail: info@hacker-net.de</p>
|
||||||
|
|
||||||
|
<h2>Umsatzsteuer-ID</h2>
|
||||||
|
|
||||||
|
<p>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br>
|
||||||
|
[Ihre USt-IdNr.]</p>
|
||||||
|
|
||||||
|
<h2>Berufsbezeichnung und berufsrechtliche Regelungen</h2>
|
||||||
|
|
||||||
|
<p>Berufsbezeichnung: Telekommunikationsdienstleister<br>
|
||||||
|
Zuständige Kammer: [IHK Oldenburg]<br>
|
||||||
|
Verliehen in: Deutschland</p>
|
||||||
|
|
||||||
|
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||||
|
|
||||||
|
<p>Stefan Hacker<br>
|
||||||
|
Am Wunderburgpark 5b<br>
|
||||||
|
26135 Oldenburg</p>
|
||||||
|
|
||||||
|
<h2>EU-Streitschlichtung</h2>
|
||||||
|
|
||||||
|
<p>Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
|
||||||
|
<a href="https://ec.europa.eu/consumers/odr/" target="_blank">https://ec.europa.eu/consumers/odr/</a>.<br>
|
||||||
|
Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
|
||||||
|
|
||||||
|
<h2>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h2>
|
||||||
|
|
||||||
|
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>`;
|
||||||
|
|
||||||
|
function MenuBar({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
||||||
|
if (!editor) return null;
|
||||||
|
const setLink = useCallback(() => {
|
||||||
|
const previousUrl = editor.getAttributes('link').href;
|
||||||
|
const url = window.prompt('URL eingeben:', previousUrl);
|
||||||
|
if (url === null) return;
|
||||||
|
if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; }
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||||
|
}, [editor]);
|
||||||
|
const btnClass = (active: boolean) => `p-1.5 rounded hover:bg-gray-200 transition-colors ${active ? 'bg-gray-200 text-blue-600' : 'text-gray-600'}`;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-gray-50">
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleBold().run()} className={btnClass(editor.isActive('bold'))} title="Fett"><Bold className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleItalic().run()} className={btnClass(editor.isActive('italic'))} title="Kursiv"><Italic className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={btnClass(editor.isActive('heading', { level: 1 }))} title="Überschrift 1"><Heading1 className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={btnClass(editor.isActive('heading', { level: 2 }))} title="Überschrift 2"><Heading2 className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={btnClass(editor.isActive('heading', { level: 3 }))} title="Überschrift 3"><Heading3 className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().setParagraph().run()} className={btnClass(editor.isActive('paragraph'))} title="Absatz"><Type className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleBulletList().run()} className={btnClass(editor.isActive('bulletList'))} title="Aufzählung"><List className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={btnClass(editor.isActive('orderedList'))} title="Nummerierung"><ListOrdered className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={setLink} className={btnClass(editor.isActive('link'))} title="Link"><LinkIcon className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Rückgängig"><Undo className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Wiederherstellen"><Redo className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImprintEditor() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['imprint'],
|
||||||
|
queryFn: () => gdprApi.getImprint(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (html: string) => gdprApi.updateImprint(html),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['imprint'] }); setSaved(true); setTimeout(() => setSaved(false), 2000); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialContent = data?.data?.html || DEFAULT_IMPRINT;
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit, TiptapLink.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } })],
|
||||||
|
content: initialContent,
|
||||||
|
editorProps: { attributes: { class: 'prose prose-sm max-w-none p-4 min-h-[400px] focus:outline-none' } },
|
||||||
|
}, [initialContent]);
|
||||||
|
|
||||||
|
const handleSave = () => { if (editor) saveMutation.mutate(editor.getHTML()); };
|
||||||
|
const handleReset = () => { if (confirm('Auf Standardtext zurücksetzen?')) editor?.commands.setContent(DEFAULT_IMPRINT); };
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<RouterLink to="/settings"><Button variant="ghost" size="sm"><ArrowLeft className="w-4 h-4" /></Button></RouterLink>
|
||||||
|
<h1 className="text-2xl font-bold flex-1">Impressum bearbeiten</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset}>Standardtext laden</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowPreview(!showPreview)}><Eye className="w-4 h-4 mr-2" />{showPreview ? 'Editor' : 'Vorschau'}</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saveMutation.isPending}><Save className="w-4 h-4 mr-2" />{saved ? 'Gespeichert!' : saveMutation.isPending ? 'Speichern...' : 'Speichern'}</Button>
|
||||||
|
</div>
|
||||||
|
{showPreview ? (
|
||||||
|
<Card><div className="prose prose-sm max-w-none p-4" dangerouslySetInnerHTML={{ __html: editor?.getHTML() || '' }} /></Card>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg bg-white overflow-hidden">
|
||||||
|
<MenuBar editor={editor} />
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveMutation.isError && <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">Fehler beim Speichern.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import TiptapLink from '@tiptap/extension-link';
|
||||||
|
import { gdprApi } from '../../services/api';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import { ArrowLeft, Save, Eye, Bold, Italic, List, ListOrdered, Heading1, Heading2, Heading3, Link as LinkIcon, Undo, Redo, Type } from 'lucide-react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
const DEFAULT_POLICY = `<h1>Datenschutzerklärung</h1>
|
||||||
|
|
||||||
|
<h2>1. Datenschutz auf einen Blick</h2>
|
||||||
|
|
||||||
|
<h3>Allgemeine Hinweise</h3>
|
||||||
|
<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie dieses Kundenportal nutzen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.</p>
|
||||||
|
|
||||||
|
<h3>Datenerfassung in diesem Portal</h3>
|
||||||
|
<p><strong>Wer ist verantwortlich für die Datenerfassung?</strong><br>
|
||||||
|
Die Datenverarbeitung erfolgt durch den Betreiber des Portals. Dessen Kontaktdaten können Sie dem Impressum entnehmen.</p>
|
||||||
|
|
||||||
|
<p><strong>Wie erfassen wir Ihre Daten?</strong><br>
|
||||||
|
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben. Andere Daten werden automatisch oder nach Ihrer Einwilligung beim Besuch des Portals durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z.B. Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs).</p>
|
||||||
|
|
||||||
|
<p><strong>Wofür nutzen wir Ihre Daten?</strong><br>
|
||||||
|
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung des Portals zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden. Hauptsächlich dienen Ihre Daten der Vertragsverwaltung und Kundenbetreuung.</p>
|
||||||
|
|
||||||
|
<h2>2. Verantwortliche Stelle</h2>
|
||||||
|
|
||||||
|
<p>Die verantwortliche Stelle für die Datenverarbeitung ist:</p>
|
||||||
|
|
||||||
|
<p>Hacker-Net Telekommunikation<br>
|
||||||
|
Stefan Hacker<br>
|
||||||
|
Am Wunderburgpark 5b<br>
|
||||||
|
26135 Oldenburg<br>
|
||||||
|
E-Mail: info@hacker-net.de</p>
|
||||||
|
|
||||||
|
<p>Verantwortliche Stelle ist die natürliche oder juristische Person, die allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten entscheidet.</p>
|
||||||
|
|
||||||
|
<h2>3. Datenerfassung im Portal</h2>
|
||||||
|
|
||||||
|
<h3>Cookies</h3>
|
||||||
|
<p>Dieses Portal verwendet Cookies ausschließlich für die Authentifizierung (Login) und Sitzungsverwaltung. Es werden keine Tracking- oder Marketing-Cookies eingesetzt.</p>
|
||||||
|
|
||||||
|
<h3>Server-Log-Dateien</h3>
|
||||||
|
<p>Der Provider des Portals erhebt und speichert automatisch Informationen in sogenannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Browsertyp und Browserversion</li>
|
||||||
|
<li>Verwendetes Betriebssystem</li>
|
||||||
|
<li>Referrer URL</li>
|
||||||
|
<li>Hostname des zugreifenden Rechners</li>
|
||||||
|
<li>Uhrzeit der Serveranfrage</li>
|
||||||
|
<li>IP-Adresse</li>
|
||||||
|
</ul>
|
||||||
|
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen. Die Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO.</p>
|
||||||
|
|
||||||
|
<h3>Registrierung und Anmeldung</h3>
|
||||||
|
<p>Sie können sich in diesem Portal mit den Ihnen mitgeteilten Zugangsdaten anmelden. Die bei der Anmeldung abgefragten Daten werden für die Bereitstellung des Portals und die Vertragsverwaltung verwendet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).</p>
|
||||||
|
|
||||||
|
<h2>4. Ihre Rechte</h2>
|
||||||
|
|
||||||
|
<p>Sie haben jederzeit das Recht:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Auskunft</strong> über Ihre bei uns gespeicherten personenbezogenen Daten zu erhalten (Art. 15 DSGVO)</li>
|
||||||
|
<li><strong>Berichtigung</strong> unrichtiger Daten zu verlangen (Art. 16 DSGVO)</li>
|
||||||
|
<li><strong>Löschung</strong> Ihrer Daten zu verlangen (Art. 17 DSGVO)</li>
|
||||||
|
<li><strong>Einschränkung</strong> der Verarbeitung zu verlangen (Art. 18 DSGVO)</li>
|
||||||
|
<li><strong>Datenübertragbarkeit</strong> zu verlangen (Art. 20 DSGVO)</li>
|
||||||
|
<li>Eine erteilte <strong>Einwilligung zu widerrufen</strong> (Art. 7 Abs. 3 DSGVO)</li>
|
||||||
|
<li>Sich bei einer <strong>Aufsichtsbehörde zu beschweren</strong> (Art. 77 DSGVO)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Zuständige Aufsichtsbehörde:<br>
|
||||||
|
Die Landesbeauftragte für den Datenschutz Niedersachsen<br>
|
||||||
|
Prinzenstraße 5, 30159 Hannover<br>
|
||||||
|
<a href="https://www.lfd.niedersachsen.de" target="_blank">www.lfd.niedersachsen.de</a></p>
|
||||||
|
|
||||||
|
<h2>5. Datensicherheit</h2>
|
||||||
|
|
||||||
|
<p>Dieses Portal nutzt aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher Inhalte eine SSL-/TLS-Verschlüsselung. Eine verschlüsselte Verbindung erkennen Sie daran, dass die Adresszeile des Browsers von "http://" auf "https://" wechselt und an dem Schloss-Symbol in Ihrer Browserzeile.</p>
|
||||||
|
|
||||||
|
<p style="color: #9ca3af; font-size: 12px; margin-top: 32px;">Stand: März 2026</p>`;
|
||||||
|
|
||||||
|
function MenuBar({ editor }: { editor: ReturnType<typeof useEditor> }) {
|
||||||
|
if (!editor) return null;
|
||||||
|
const setLink = useCallback(() => {
|
||||||
|
const previousUrl = editor.getAttributes('link').href;
|
||||||
|
const url = window.prompt('URL eingeben:', previousUrl);
|
||||||
|
if (url === null) return;
|
||||||
|
if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; }
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||||
|
}, [editor]);
|
||||||
|
const btnClass = (active: boolean) => `p-1.5 rounded hover:bg-gray-200 transition-colors ${active ? 'bg-gray-200 text-blue-600' : 'text-gray-600'}`;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-gray-50">
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleBold().run()} className={btnClass(editor.isActive('bold'))} title="Fett"><Bold className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleItalic().run()} className={btnClass(editor.isActive('italic'))} title="Kursiv"><Italic className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={btnClass(editor.isActive('heading', { level: 1 }))} title="Überschrift 1"><Heading1 className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={btnClass(editor.isActive('heading', { level: 2 }))} title="Überschrift 2"><Heading2 className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={btnClass(editor.isActive('heading', { level: 3 }))} title="Überschrift 3"><Heading3 className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().setParagraph().run()} className={btnClass(editor.isActive('paragraph'))} title="Absatz"><Type className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleBulletList().run()} className={btnClass(editor.isActive('bulletList'))} title="Aufzählung"><List className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().toggleOrderedList().run()} className={btnClass(editor.isActive('orderedList'))} title="Nummerierung"><ListOrdered className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={setLink} className={btnClass(editor.isActive('link'))} title="Link"><LinkIcon className="w-4 h-4" /></button>
|
||||||
|
<div className="w-px h-5 bg-gray-300 mx-1" />
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Rückgängig"><Undo className="w-4 h-4" /></button>
|
||||||
|
<button type="button" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} className="p-1.5 rounded hover:bg-gray-200 text-gray-600 disabled:opacity-30" title="Wiederherstellen"><Redo className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebsitePrivacyPolicyEditor() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['website-privacy-policy'],
|
||||||
|
queryFn: () => gdprApi.getWebsitePrivacyPolicy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (html: string) => gdprApi.updateWebsitePrivacyPolicy(html),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['website-privacy-policy'] }); setSaved(true); setTimeout(() => setSaved(false), 2000); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialContent = data?.data?.html || DEFAULT_POLICY;
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit, TiptapLink.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } })],
|
||||||
|
content: initialContent,
|
||||||
|
editorProps: { attributes: { class: 'prose prose-sm max-w-none p-4 min-h-[400px] focus:outline-none' } },
|
||||||
|
}, [initialContent]);
|
||||||
|
|
||||||
|
const handleSave = () => { if (editor) saveMutation.mutate(editor.getHTML()); };
|
||||||
|
const handleReset = () => { if (confirm('Auf Standardtext zurücksetzen?')) editor?.commands.setContent(DEFAULT_POLICY); };
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center py-8 text-gray-500">Laden...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<RouterLink to="/settings"><Button variant="ghost" size="sm"><ArrowLeft className="w-4 h-4" /></Button></RouterLink>
|
||||||
|
<h1 className="text-2xl font-bold flex-1">Website-Datenschutzerklärung bearbeiten</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset}>Standardtext laden</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowPreview(!showPreview)}><Eye className="w-4 h-4 mr-2" />{showPreview ? 'Editor' : 'Vorschau'}</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saveMutation.isPending}><Save className="w-4 h-4 mr-2" />{saved ? 'Gespeichert!' : saveMutation.isPending ? 'Speichern...' : 'Speichern'}</Button>
|
||||||
|
</div>
|
||||||
|
{showPreview ? (
|
||||||
|
<Card><div className="prose prose-sm max-w-none p-4" dangerouslySetInnerHTML={{ __html: editor?.getHTML() || '' }} /></Card>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg bg-white overflow-hidden">
|
||||||
|
<MenuBar editor={editor} />
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveMutation.isError && <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">Fehler beim Speichern.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1396,6 +1396,24 @@ export const gdprApi = {
|
||||||
const res = await api.put<ApiResponse<void>>('/gdpr/authorization-template', { html });
|
const res = await api.put<ApiResponse<void>>('/gdpr/authorization-template', { html });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// Impressum
|
||||||
|
getImprint: async () => {
|
||||||
|
const res = await api.get<ApiResponse<{ html: string }>>('/gdpr/imprint');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateImprint: async (html: string) => {
|
||||||
|
const res = await api.put<ApiResponse<void>>('/gdpr/imprint', { html });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
// Website-Datenschutzerklärung
|
||||||
|
getWebsitePrivacyPolicy: async () => {
|
||||||
|
const res = await api.get<ApiResponse<{ html: string }>>('/gdpr/website-privacy-policy');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateWebsitePrivacyPolicy: async (html: string) => {
|
||||||
|
const res = await api.put<ApiResponse<void>>('/gdpr/website-privacy-policy', { html });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
// Consent-Link senden
|
// Consent-Link senden
|
||||||
sendConsentLink: async (customerId: number, channel: string) => {
|
sendConsentLink: async (customerId: number, channel: string) => {
|
||||||
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/gdpr/customer/${customerId}/send-consent-link`, { channel });
|
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/gdpr/customer/${customerId}/send-consent-link`, { channel });
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8443:8443" # Plesk Panel (HTTPS)
|
- "8443:8443" # Plesk Panel (HTTPS)
|
||||||
- "8880:8880" # Plesk Panel (HTTP)
|
- "8880:8880" # Plesk Panel (HTTP)
|
||||||
#- "25:25" # SMTP
|
- "25:25" # SMTP
|
||||||
- "465:465" # SMTPS
|
- "465:465" # SMTPS
|
||||||
- "587:587" # Submission
|
- "587:587" # Submission
|
||||||
- "143:143" # IMAP
|
- "143:143" # IMAP
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue