${o.message}
` : ''} + ${o.message ? `${o.message}
` : ''} ${progress} @@ -142,12 +142,31 @@ function remove(wrapper) { if (!wrapper) return; - const card = wrapper.firstElementChild; - if (!card) return wrapper.remove(); + + // wir animieren das eigentliche Card-Element (erstes Kind) + const card = wrapper.firstElementChild || wrapper; + + // Wenn schon im Ausblenden, doppelt nicht starten + if (card.classList.contains('tg-out')) return; + + // Ausblend-Animation starten + card.classList.remove('tg-in'); card.classList.add('tg-out'); - card.addEventListener('animationend', () => { + + // Sicher entfernen, wenn die Animation fertig ist + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + // kompletten Wrapper aus dem DOM wrapper.remove(); - }, { once: true }); + }; + + // Normalfall: nach Animation + card.addEventListener('animationend', cleanup, { once: true }); + + // Fallback: falls animationend aus irgendeinem Grund nicht feuert + setTimeout(cleanup, 600); // > .28s; gib etwas Puffer } // ---- Public API ---------------------------------------------------------- diff --git a/resources/js/plugins/Toastra/src/message.css b/resources/js/plugins/Toastra/src/message.css index 7dd5b8b..ae0149d 100644 --- a/resources/js/plugins/Toastra/src/message.css +++ b/resources/js/plugins/Toastra/src/message.css @@ -735,7 +735,7 @@ .notification-title { display: flex; align-items: center; - gap: .5rem; /* space between badge and title */ + gap: .5rem; /* Space between badge and title */ line-height: 1.2; margin-bottom: .15rem; } diff --git a/resources/js/ui/toast.js b/resources/js/ui/toast.js new file mode 100644 index 0000000..b198013 --- /dev/null +++ b/resources/js/ui/toast.js @@ -0,0 +1,25 @@ +// Minimal-API mit Fallbacks: GlassToastra → toastr → eigener Mini-Toast +export function showToast({ type = 'success', text = '', title = '' } = {}) { + const t = (type || 'success').toLowerCase(); + const msg = text || ''; + + // 1) Dein Glas-Toast + if (window.GlassToastra && typeof window.GlassToastra[t] === 'function') { + window.GlassToastra[t](msg, title); + return; + } + // 2) toastr + if (window.toastr && typeof window.toastr[t] === 'function') { + window.toastr.options = { timeOut: 3500, progressBar: true, closeButton: true }; + window.toastr[t](msg, title); + return; + } + // 3) Fallback + const box = document.createElement('div'); + box.className = + 'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white ' + + 'px-4 py-3 backdrop-blur shadow-lg border border-white/10'; + box.textContent = msg; + document.body.appendChild(box); + setTimeout(() => box.remove(), 3500); +} diff --git a/resources/js/utils/events.js b/resources/js/utils/events.js index 914ca5e..1385ebf 100644 --- a/resources/js/utils/events.js +++ b/resources/js/utils/events.js @@ -1,25 +1,67 @@ +import { showToast } from '../ui/toast.js' + +// — Livewire-Hooks (global) document.addEventListener('livewire:init', () => { - Livewire.on('toastra:show', (payload) => { - // optionaler "mute" pro Nutzer lokal: - if (localStorage.getItem('toast:hide:' + payload.id)) return; + if (window.Livewire?.on) { + window.Livewire.on('toast', (payload = {}) => showToast(payload)) + } +}) - const id = window.toastraGlass.show({ - id: payload.id, - state: payload.state, // queued|running|done|failed - badge: payload.badge, - domain: payload.domain, - message: payload.message, - position: payload.position || 'bottom-center', - duration: payload.duration ?? 0, - close: payload.close !== false, - }); +// — Session-Flash vom Backend (einmal pro Page-Load) +function bootstrapFlashFromLayout() { + const el = document.getElementById('__flash') + if (!el) return + try { + const data = JSON.parse(el.textContent || '{}') + if (data?.toast) showToast(data.toast) + } catch {} +} +document.addEventListener('DOMContentLoaded', bootstrapFlashFromLayout) - // Wenn der User X klickt, markiere lokal als verborgen: - window.addEventListener('toastra:closed:' + id, () => { - localStorage.setItem('toast:hide:' + id, '1'); - }, { once: true }); - }); -}); +// — Optional: Echo/WebSocket-Kanal für „Push-Toasts“ +function setupEchoToasts() { + if (!window.Echo) return + // userId wird im Layout in das JSON injiziert (siehe unten) + const el = document.getElementById('__flash') + let uid = null + try { uid = JSON.parse(el?.textContent || '{}')?.userId ?? null } catch {} + if (!uid) return + + window.Echo.private(`users.${uid}`) + .listen('.ToastPushed', (e) => { + // e: { type, text, title } + showToast(e) + }) +} +document.addEventListener('DOMContentLoaded', setupEchoToasts) + +// — Optional: global machen, falls du manuell aus JS/Blade rufen willst +window.showToast = showToast + + + +// document.addEventListener('livewire:init', () => { +// Livewire.on('toastra:show', (payload) => { +// // optionaler "mute" pro Nutzer lokal: +// if (localStorage.getItem('toast:hide:' + payload.id)) return; +// +// const id = window.toastraGlass.show({ +// id: payload.id, +// state: payload.state, // queued|running|done|failed +// badge: payload.badge, +// domain: payload.domain, +// message: payload.message, +// position: payload.position || 'bottom-center', +// duration: payload.duration ?? 0, +// close: payload.close !== false, +// }); +// +// // Wenn der User X klickt, markiere lokal als verborgen: +// window.addEventListener('toastra:closed:' + id, () => { +// localStorage.setItem('toast:hide:' + id, '1'); +// }, { once: true }); +// }); +// }); // document.addEventListener('livewire:init', () => { // Livewire.on('notify', (payload) => { // const o = Array.isArray(payload) ? payload[0] : payload; diff --git a/resources/js/webserver/connection.js b/resources/js/webserver/connection.js new file mode 100644 index 0000000..12e6a24 --- /dev/null +++ b/resources/js/webserver/connection.js @@ -0,0 +1,17 @@ +import Echo from 'laravel-echo'; + +import Pusher from 'pusher-js'; +window.Pusher = Pusher; + +window.Echo = new Echo({ + broadcaster: 'reverb', + key: import.meta.env.VITE_REVERB_APP_KEY, + wsHost: import.meta.env.VITE_REVERB_HOST, + wsPort: import.meta.env.VITE_REVERB_PORT ?? 80, + wssPort: import.meta.env.VITE_REVERB_PORT ?? 443, + wsPath: '/ws', + forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', + enabledTransports: ['ws', 'wss'], +}); + +export default Echo; diff --git a/resources/js/webserver/events.js b/resources/js/webserver/events.js new file mode 100644 index 0000000..d619b6a --- /dev/null +++ b/resources/js/webserver/events.js @@ -0,0 +1,24 @@ +// window.Echo.channel('demo') +// .listen('.DemoPing', (e) => { +// console.log('[Reverb] DemoPing:', e); +// window.toastraGlass?.show({ +// id: 'demo', state: 'done', badge: 'Broadcast', +// domain: 'DemoPing', message: e.msg, duration: 3000 +// }); +// }); + +export function initEvents(echo) { + echo.channel('system') + .listen('.cert.ping', (e) => { + console.log('[WS] cert.ping', e); + window.toastraGlass?.show({ + id: 'cert-ping', + state: 'running', + badge: 'Zertifikat', + domain: 'Signal', + message: e.message, + position: 'bottom-right', + duration: 3000, + }); + }); +} diff --git a/resources/js/webserver/websocket.js b/resources/js/webserver/websocket.js index 37708ca..dcf7298 100644 --- a/resources/js/webserver/websocket.js +++ b/resources/js/webserver/websocket.js @@ -1,23 +1,414 @@ -import Echo from 'laravel-echo' -import Pusher from 'pusher-js' -import { wsConfig } from './connector.js' +import './connection.js'; -window.Pusher = Pusher +// 1) Initial aus Redis laden, damit Toasts auch nach Redirect sichtbar sind +// async function bootstrapToasts() { +// try { +// const res = await fetch('/api/tasks/active', { +// credentials: 'same-origin', +// headers: { +// 'Accept': 'application/json', +// 'X-Requested-With': 'XMLHttpRequest', +// }, +// }); +// +// // Klarer Fehlerfall statt blind json() zu rufen +// const ct = res.headers.get('content-type') || ''; +// if (!res.ok) { +// const text = await res.text(); +// throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); +// } +// if (!ct.includes('application/json')) { +// const text = await res.text(); +// console.warn('Initial toast fetch: non-JSON response', text.substring(0, 300)); +// return; +// } +// +// const json = await res.json(); +// (json.items || []).forEach(renderOrUpdateToast); +// } catch (e) { +// console.warn('Initial toast fetch failed', e); +// } +// } +// +// bootstrapToasts(); +// 2) Live via WebSocket -const host = wsConfig.host || window.location.hostname -const port = Number(wsConfig.port) || 443 // <— port! -const scheme = (wsConfig.scheme || 'https').toLowerCase() -const path = wsConfig.path || '/ws' -const tls = scheme === 'https' // <— boolean -const key = wsConfig.key +// function renderOrUpdateToast(snap) { +// const { +// id, +// type = 'issue-cert', +// status = 'queued', // queued|running|done|failed +// message = '', +// payload = {}, +// } = snap || {}; +// +// const pos = 'bottom-right'; +// const badge = (type === 'issue-cert') ? 'Zertifikat' : type; +// const domain = payload?.domain ?? ''; +// +// // Dauer: done/failed => 6s, sonst offen +// const duration = (status === 'done' || status === 'failed') ? 6000 : -1; +// +// // deine Glas-UI +// toastraGlass.show({ +// id, // wichtig, damit spätere Updates denselben Toast treffen +// state: status, +// badge, +// domain, +// message, +// position: pos, +// duration +// }); +// } +// +// async function bootstrapToasts() { +// try { +// const res = await fetch('/ui/tasks/active', { +// credentials: 'same-origin', +// headers: { 'Accept': 'application/json' } +// }); +// +// if (!res.ok) { +// console.warn('Initial toast fetch HTTP', res.status); +// return; +// } +// +// const ct = res.headers.get('content-type') || ''; +// if (!ct.includes('application/json')) { +// console.warn('Initial toast fetch: unexpected content-type', ct); +// return; +// } +// +// const json = await res.json(); +// (json.items || []).forEach(renderOrUpdateToast); +// } catch (e) { +// console.warn('Initial toast fetch failed', e); +// } +// } +// bootstrapToasts(); +// +// Echo.channel('system.tasks') +// .listen('.task.updated', (e) => { +// // Optional: nur eigene User-Events zeigen +// // if (e.userId !== window.App?.user?.id) return; +// +// renderOrUpdateToast(e.payload); +// }); -window.Echo = new Echo({ - broadcaster: 'reverb', - key, - wsHost: host, - wsPort: port, - wssPort: port, - forceTLS: tls, - enabledTransports: ['ws','wss'], - wsPath: path, -}) + +// const seen = new Map(); // id -> lastState +// +// async function bootstrapToasts() { +// try { +// const res = await fetch('/tasks/active', { +// credentials: 'same-origin', +// headers: { 'Accept': 'application/json' }, // <- erzwinge JSON +// }); +// if (!res.ok) { +// const text = await res.text(); +// console.warn('Initial toast fetch non-200:', res.status, text); +// return; +// } +// const { items = [] } = await res.json(); +// items.forEach(renderOrUpdateToast); +// } catch (e) { +// console.warn('Initial toast fetch failed', e); +// } +// } +// bootstrapToasts(); +// +// function getCsrf() { +// const m = document.head.querySelector('meta[name="csrf-token"]'); +// return m ? m.content : ''; +// } +// +// async function ack(id) { +// try { +// await fetch('/tasks/ack', { +// method: 'POST', +// credentials: 'same-origin', +// headers: { +// 'Content-Type': 'application/json', +// 'X-CSRF-TOKEN': getCsrf(), // <- wichtig bei POST +// 'Accept': 'application/json', +// }, +// body: JSON.stringify({ id }), +// }); +// } catch (_) {} +// } +// function renderOrUpdateToast(item) { +// const { id, state, badge, domain, message, progress = 0 } = item; +// const last = seen.get(id); +// if (last === state) return; // dedupe +// +// seen.set(id, state); +// +// // dein Glas-Toast +// toastraGlass.show({ +// id, +// state, // queued|running|done|failed +// badge, +// domain, +// message, +// progress, // optional für Fortschrittsbalken +// position: 'bottom-right', +// duration: (state === 'done' || state === 'failed') ? 8000 : -1, +// onClose: () => ack(id), // Nutzer schließt → ebenfalls ack +// }); +// +// // Terminalstatus → direkt ack senden (oder erst onClose, wie du magst) +// if (state === 'done' || state === 'failed') { +// ack(id); +// } +// } +// +// +// // WebSocket Live-Updates +// window.Echo.channel('system.tasks') +// .listen('.cert.status', (e) => { +// // e: { id, state, message, progress, ... } +// renderOrUpdateToast(e); +// }); + +// 3) Renderer (Toastra Glass Bridge) +// const known = new Map(); // taskId -> toastUiId +// +// function renderOrUpdateToast(payload) { +// const id = payload.id; +// const state = payload.state; // queued|running|done|failed +// const msg = payload.message || ''; +// +// const opts = { +// id, // WICHTIG: stabile ID für replace +// state: state, // unser Toastra erwartet 'done'|'failed' um Auto-Close zu setzen +// badge: payload.badge ?? null, +// domain: payload.title ?? 'System', +// message: msg, +// position: payload.position ?? 'bottom-right', +// duration: (state === 'done' || state === 'failed') ? 6000 : 0, // läuft weiter bis Update +// }; +// +// if (known.has(id)) { +// window.toastraGlass.update(known.get(id), opts); +// } else { +// const toastUiId = window.toastraGlass.show(opts); +// known.set(id, toastUiId); +// } +// +// // Optional: bei "done|failed" nach n Sekunden als "gesehen" acken +// if (state === 'done' || state === 'failed') { +// setTimeout(() => { +// fetch(`/api/tasks/${encodeURIComponent(id)}/ack`, { method: 'POST', credentials: 'same-origin' }) +// .catch(()=>{}); +// }, 7000); +// } +// } + + + +// import Echo from 'laravel-echo' +// import Pusher from 'pusher-js' +// import {wsConfig} from './connector.js' +// import {initEvents} from "./events.js"; +// +// window.Pusher = Pusher +// +// window.Pusher && (window.Pusher.logToConsole = true); // Debug an +// +// const host = wsConfig.host || window.location.hostname +// const port = Number(wsConfig.port) || 443 // <— port! +// const scheme = (wsConfig.scheme || 'https').toLowerCase() +// const path = wsConfig.path || '/ws' +// const tls = scheme === 'https' // <— boolean +// const key = wsConfig.key +// +// window.Echo = new Echo({ +// broadcaster: 'reverb', +// key, +// wsHost: host, +// wsPort: port, +// wssPort: port, +// forceTLS: tls, +// enabledTransports: ['ws', 'wss'], +// wsPath: path, +// }) +// +// window.Echo.channel('system.tasks') +// .listen('.cert.ping', (e) => { +// console.log('Task-Event:', e); // hier sollte die Nachricht aufschlagen +// }); + + +// Echo.channel('system.tasks') +// .listen('.cert.ping', (e) => { +// console.log('Task-Event:', e); // hier sollte die Nachricht aufschlagen +// }); + +// Echo.channel('system.tasks') +// .listen('.cert.status', (e) => { +// // Mappe Status zu deiner Toast-API +// const stateToTitle = { +// queued: 'Wartet…', +// running: 'Erstellt…', +// done: 'Fertig', +// failed: 'Fehlgeschlagen', +// }; +// +// // Dauer: während queued/running immer sichtbar (−1), bei final 5s +// const duration = (e.state === 'done' || e.state === 'failed') ? 5000 : -1; +// +// // Optional: Fortschrittsbalken kannst du selbst in toastraGlass implementieren +// window.toastraGlass?.show({ +// state: e.state, // queued|running|done|failed +// badge: 'Zertifikat', +// domain: e.key.replace('issue-cert:',''), +// message: e.message, +// position: 'bottom-right', +// duration, +// progress: e.progress ?? 0, +// }); +// }); + + +// const seen = new Map(); // id -> lastState +// +// function renderOrUpdateToast(o) { +// // Toastra expects: {state,badge,domain,message,progress,position,id,duration} +// const pos = o.pos || 'bottom-right'; +// const id = o.id; +// +// // Dedupe / Update +// const last = seen.get(id); +// const key = `${o.status}|${o.progress}|${o.message}`; +// if (last === key) return; +// +// seen.set(id, key); +// +// // Zeichnen / Aktualisieren +// window.toastraGlass.show({ +// state: mapState(o.status), // queued|running|done|failed +// badge: o.title || 'ZERTIFIKAT', +// domain: o.payload?.domain || o.domain || '', +// message: o.message || '', +// progress: Number(o.progress ?? 0), +// position: pos, +// id, +// duration: (o.status === 'done' || o.status === 'failed') ? 4000 : -1, +// }); +// +// // Bei done/failed sofort aus dem Local-Cache entfernen +// if (o.status === 'done' || o.status === 'failed') { +// setTimeout(() => { +// seen.delete(id); +// }, 4500); +// } +// } +// +// function mapState(s) { +// if (s === 'queued') return 'queued'; +// if (s === 'running') return 'running'; +// if (s === 'done') return 'done'; +// if (s === 'failed') return 'failed'; +// return 'info'; +// } +// +// // 1) Initiale Tasks laden – **neue** Route mit web+auth! +// async function bootstrapToasts() { +// try { +// const res = await fetch('/ui/tasks/active', { +// credentials: 'same-origin', +// headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, +// }); +// if (!res.ok) throw new Error(`HTTP ${res.status}`); +// const { items = [] } = await res.json(); +// items.forEach(renderOrUpdateToast); +// } catch (e) { +// console.warn('Initial toast fetch failed', e); +// } +// } +// bootstrapToasts(); +// +// // 2) Echtzeit-Updates +// window.Echo.channel('system.tasks') +// .listen('.cert.status', (e) => { +// // e: { id,status,message,progress,domain,mode } +// renderOrUpdateToast({ +// id: e.id, +// status: e.status, +// message: e.message, +// progress: e.progress, +// title: (e.mode || 'ZERTIFIKAT').toUpperCase(), +// domain: e.domain, +// }); +// }); + +const seen = new Map(); // id -> lastState + +function labelForState(state) { + switch ((state || '').toLowerCase()) { + case 'queued': return 'Wartet…'; + case 'running': return 'Läuft…'; + case 'done': return 'Erledigt'; + case 'failed': return 'Fehlgeschlagen'; + default: return state || 'Unbekannt'; + } +} + +function renderOrUpdateToast(ev) { + const id = ev.id; + const state = (ev.state || '').toLowerCase(); + const text = ev.message || ''; + const prog = typeof ev.progress === 'number' ? ev.progress : null; + + // Duplikate vermeiden: nur reagieren, wenn sich der State geändert hat + const last = seen.get(id); + if (last === state) return; + seen.set(id, state); + + // Dein Toastra: + window.toastraGlass?.show({ + id, + state, // queued|running|done|failed (wichtig!) + badge: 'ZERTIFIKAT', + domain: text, // Sub-Headline + message: text, // Main-Text + progress: prog, // 0..100 (optional) + position: 'bottom-right', + duration: (state === 'done' || state === 'failed') ? 5000 : -1, + // falls dein Renderer eine Status-Beschriftung braucht: + statusLabel: labelForState(state), + }); + + // Final? -> nach kurzer Zeit ausblenden (UI) + if (ev.meta && ev.meta.final) { + setTimeout(() => { + window.toastraGlass?.removeById?.(id); + // Alternativ (wenn removeById fehlt): + // document.querySelectorAll(`[data-toast-id="${CSS.escape(id)}"]`).forEach(n => n.remove()); + seen.delete(id); + }, 5200); + } +} + +// Initiale Tasks aus dem Backend laden (damit Redirect-Toasts sichtbar bleiben) +async function bootstrapToasts() { + try { + const res = await fetch('/ui/tasks/active', { + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + (data.items || []).forEach(renderOrUpdateToast); + } catch (e) { + console.warn('Initial toast fetch failed', e); + } +} +bootstrapToasts(); + +// WebSocket-Listener +window.Echo + .channel('system.tasks') + .listen('.cert.status', (payload) => { + renderOrUpdateToast(payload); + }); diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 7ea474c..5b50058 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -4,7 +4,74 @@ @section('title', 'Login') @section('content') ---}} +{{-- Melde dich mit dem einmaligen Bootstrap-Konto an, um den Setup-Wizard zu starten.--}} +{{--
--}} + +{{-- --}}{{-- Fehler (optional) --}} +{{-- @if(session('error'))--}} +{{--Anmeldung fehlgeschlagen
--}} +{{--{{ session('error') }}
--}} +{{----}} +{{-- Erstelle ein Konto, um mit MailWolt zu starten.--}} +{{--
--}} + +{{--+
Melde dich mit dem einmaligen Bootstrap-Konto an, um den Setup-Wizard zu starten.
- @if ($error) -Anmeldung fehlgeschlagen
-{{ $error }}
-Anmeldung fehlgeschlagen
+{{ $error }}
+ Erstelle ein Konto, um mit MailWolt zu starten. +
+ + + + {{-- kleines clientseitiges Toggle, löst kein Livewire-Event aus --}} + +Klick → Event cert.ping auf Channel system.tasks.
| Benutzer | +IP-Adresse | +Zeitpunkt | +
|---|---|---|
|
+
+
+ {{ strtoupper(substr($r['user'],0,1)) }}
+
+ {{ $r['user'] }}
+
+ |
+ {{ $r['ip'] }} | +{{ $r['time'] }} | +
+ Setze die folgenden Records in deiner DNS-Zone für + {{ $base }}. +
+{{ $r['value'] }}
+ @if(!empty($r['helpUrl']))
+
+ {{ $r['helpLabel'] }}
+
+ @endif
+ {{ $r['value'] }}
+ Wird in Übersichten und Benachrichtigungen angezeigt.
+Kurzname für Login & UI.
+Änderung erfolgt rechts in „E-Mail ändern“.
+{{ $message }}
@enderror + @error('new_password_confirmation'){{ $message }}
@enderror +Wir senden dir einen Bestätigungslink an die neue Adresse.
+ @error('email_current'){{ $message }}
@enderror + @error('email_new'){{ $message }}
@enderror ++ Wir senden einen 6-stelligen Bestätigungscode an deine aktuelle Account-E-Mail. + Gib ihn unten ein, um E-Mail-2FA zu aktivieren. +
+ + {{-- Senden --}} ++ Scanne den QR-Code mit deiner Authenticator-App und gib den 6-stelligen Code zur Bestätigung ein. +
+Kannst du nicht scannen?
+Gib stattdessen diesen Secret-Key ein:
+ ++ Achte darauf, dass die Gerätezeit korrekt ist. +
+{{-- --}} + + +{{-- --}} +{{ $message }}
@enderror + +{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $this->mtaStsTxtName }}
+ → {{ $this->mtaStsTxtValue }}
+ Wird bei der Installation festgelegt (read-only).
+{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror +| Domain | --}} +{{--Aktiv | --}} +{{--Typ | --}} +{{----}} +{{-- |
|---|---|---|---|
| {{ $d->domain }} | --}} +{{----}} +{{-- --}} +{{-- {{ $d->is_active ? 'aktiv' : 'inaktiv' }}--}} +{{-- --}} +{{-- | --}} +{{----}} +{{-- --}} +{{-- {{ $d->is_system ? 'System' : 'Kunde' }}--}} +{{-- --}} +{{-- | --}} +{{----}}
+{{-- | --}}
+{{--
Richtlinien und Kontoschutz.
+Richtlinien und technische Schutzmaßnahmen.
--}} +{{--TLS-Versionen & Cipher-Suites konfigurieren.
--}} +{{--Login-Versuche & API-Calls begrenzen.
--}} +{{--Sicherheitsrelevante Ereignisse einsehen.
--}} +{{--+ Instanzname, Sprache und Zeitkonfiguration für die gesamte Installation. +
++ Verwaltung der System-Domain, Subdomains und TLS-Zertifikate. +
+