diff --git a/app/Console/Commands/GenerateTlsaRecord.php b/app/Console/Commands/GenerateTlsaRecord.php new file mode 100644 index 0000000..cef8e0e --- /dev/null +++ b/app/Console/Commands/GenerateTlsaRecord.php @@ -0,0 +1,95 @@ +argument('domainId')); + if (!$domain) { + $this->error('Domain nicht gefunden.'); + return self::FAILURE; + } + + // Host bestimmen + $host = trim($this->option('host') ?: Hostnames::mta()); + if (!str_contains($host, '.')) { + $this->error("Ungültiger Host: {$host}"); + return self::FAILURE; + } + + $service = $this->option('service') ?: '_25._tcp'; + $usage = (int) $this->option('usage'); + $selector = (int) $this->option('selector'); + $matching = (int) $this->option('matching'); + + // Let’s Encrypt Pfad (ggf. anpassen, falls anderes CA/Verzeichnis) + $certPath = "/etc/letsencrypt/live/{$host}/fullchain.pem"; + + if (!is_file($certPath)) { + $this->error("Zertifikat nicht gefunden: {$certPath}"); + $this->line('Tipp: LE deploy hook/renewal erst durchlaufen lassen oder Pfad anpassen.'); + return self::FAILURE; + } + + // Hash über SPKI (selector=1) + SHA-256 (matching=1) + $cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey" + . " | openssl pkey -pubin -outform DER" + . " | openssl dgst -sha256"; + $proc = Process::fromShellCommandline($cmd); + $proc->run(); + + if (!$proc->isSuccessful()) { + $this->error('Fehler bei der Hash-Erzeugung (openssl).'); + $this->line($proc->getErrorOutput()); + return self::FAILURE; + } + + $hash = preg_replace('/^SHA256\(stdin\)=\s*/', '', trim($proc->getOutput())); + + $record = TlsaRecord::updateOrCreate( + [ + 'domain_id' => $domain->id, + 'host' => $host, + 'service' => $service, + ], + [ + 'usage' => $usage, + 'selector' => $selector, + 'matching' => $matching, + 'hash' => $hash, + 'cert_path' => $certPath, + ] + ); + + $this->info('✅ TLSA gespeichert'); + $this->line(sprintf( + '%s.%s IN TLSA %d %d %d %s', + $record->service, + $record->host, + $record->usage, + $record->selector, + $record->matching, + $record->hash + )); + + return self::SUCCESS; + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php new file mode 100644 index 0000000..be0db15 --- /dev/null +++ b/app/Enums/Role.php @@ -0,0 +1,15 @@ + $this->message]; } +} diff --git a/app/Events/CertProvisionProgress.php b/app/Events/CertProvisionProgress.php new file mode 100644 index 0000000..b2455cd --- /dev/null +++ b/app/Events/CertProvisionProgress.php @@ -0,0 +1,42 @@ +taskKey); + } + + public function broadcastAs(): string + { + return 'cert.progress'; + } + + public function broadcastWith(): array + { + return [ + 'taskKey' => $this->taskKey, + 'status' => $this->status, + 'message' => $this->message, + 'mode' => $this->mode, + ]; + } +} diff --git a/app/Events/CertStatusUpdated.php b/app/Events/CertStatusUpdated.php new file mode 100644 index 0000000..a13b248 --- /dev/null +++ b/app/Events/CertStatusUpdated.php @@ -0,0 +1,45 @@ +..., 'mode'=>..., 'final'=>true, ...] + */ + public function __construct( + public string $id, + public string $state, + public string $message, + public int $progress = 0, + public array $meta = [], + ) {} + + public function broadcastOn(): Channel + { + return new Channel('system.tasks'); + } + + public function broadcastAs(): string + { + return 'cert.status'; + } + + public function broadcastWith(): array + { + return [ + 'id' => $this->id, + 'state' => $this->state, // queued|running|done|failed + 'message' => $this->message, + 'progress' => $this->progress, // 0..100 + 'meta' => $this->meta, // beliebige Zusatzinfos + ]; + } +} diff --git a/app/Events/TaskUpdated.php b/app/Events/TaskUpdated.php new file mode 100644 index 0000000..6e52e2d --- /dev/null +++ b/app/Events/TaskUpdated.php @@ -0,0 +1,35 @@ + $this->taskId, + 'userId' => $this->userId, + 'payload' => $this->payload, + ]; + } +} diff --git a/app/Events/Test.php b/app/Events/Test.php new file mode 100644 index 0000000..6d32e61 --- /dev/null +++ b/app/Events/Test.php @@ -0,0 +1,36 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Helpers/helpers.php b/app/Helpers/helpers.php index bcb16d7..99070af 100644 --- a/app/Helpers/helpers.php +++ b/app/Helpers/helpers.php @@ -5,3 +5,32 @@ if (! function_exists('settings')) { return app(\App\Support\SettingsRepository::class)->get($key, $default); } } + +if (!function_exists('domain_host')) { + function domain_host(string $sub = null): string + { + $base = env('BASE_DOMAIN', 'example.com'); + return $sub ? "{$sub}.{$base}" : $base; + } +} + +if (!function_exists('ui_host')) { + function ui_host(): string + { + return domain_host(env('UI_SUB', 'ui')); + } +} + +if (!function_exists('webmail_host')) { + function webmail_host(): string + { + return domain_host(env('WEBMAIL_SUB', 'webmail')); + } +} + +if (!function_exists('mta_host')) { + function mta_host(): string + { + return domain_host(env('MTA_SUB', 'mx')); + } +} diff --git a/app/Http/Controllers/Api/TaskFeedController.php b/app/Http/Controllers/Api/TaskFeedController.php new file mode 100644 index 0000000..a9aeda5 --- /dev/null +++ b/app/Http/Controllers/Api/TaskFeedController.php @@ -0,0 +1,33 @@ +id}:tasks"; + + $taskKeys = Redis::smembers($setKey) ?: []; + $items = []; + + foreach ($taskKeys as $k) { + // wir lesen aus dem Cache-Store "redis" + $payload = Cache::store('redis')->get($k); + if (!$payload) { + // verwaist -> aus dem Set räumen + Redis::srem($setKey, $k); + continue; + } + $items[] = array_merge(['id' => $k], $payload); + } + + return response()->json(['items' => $items]); + } +} diff --git a/app/Http/Controllers/Api/TaskStatusController.php b/app/Http/Controllers/Api/TaskStatusController.php new file mode 100644 index 0000000..572592f --- /dev/null +++ b/app/Http/Controllers/Api/TaskStatusController.php @@ -0,0 +1,74 @@ +user()->id; // du hast in Redis bereits "user:{id}:tasks" + $keySet = "user:{$userId}:tasks"; + + $taskKeys = Redis::smembers($keySet) ?: []; + $items = []; + + foreach ($taskKeys as $key) { + $snap = Cache::store('redis')->get($key); + if ($snap) { + // erwartet: ['type','status','message','payload'=>['domain'=>..], ...] + $items[] = ['id' => $key] + $snap; + } + } + + return response()->json([ + 'ok' => true, + 'count' => count($items), + 'items' => $items, + ]); + } + + public function active(Request $request) + { + $userId = Auth::id() ?? 0; // oder feste 3, wenn du noch ohne Login testest + $setKey = "user:{$userId}:tasks"; + + $items = []; + foreach (Redis::smembers($setKey) ?? [] as $taskKey) { + if ($payload = Cache::store('redis')->get($taskKey)) { + $items[] = ['id' => $taskKey] + $payload; + } + } + + return response()->json(['items' => $items]); + } + +// public function active(Request $request) +// { +// // Wenn du pro-User Toaster verwaltest: +// $userId = Auth::id() ?? 0; +// +// // Wir gehen davon aus, dass du eine Set-Liste der Keys pflegst, +// // z.B. "ui:toasts:users:{id}" und unter jedem $taskKey ein JSON-Objekt im Cache liegt. +// $setKey = "ui:toasts:users:{$userId}"; +// $keys = Redis::smembers($setKey) ?? []; +// +// $items = []; +// foreach ($keys as $key) { +// $payload = Cache::store('redis')->get($key); +// if ($payload) { +// $items[] = array_merge(['id' => $key], $payload); +// } +// } +// +// // Immer JSON liefern – kein Redirect, keine Blade-View +// return response()->json([ +// 'items' => $items, +// ]); +// } +} diff --git a/app/Http/Controllers/Auth/LoginPageController.php b/app/Http/Controllers/Auth/LoginController.php similarity index 83% rename from app/Http/Controllers/Auth/LoginPageController.php rename to app/Http/Controllers/Auth/LoginController.php index 5b085c1..94464ae 100644 --- a/app/Http/Controllers/Auth/LoginPageController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -class LoginPageController extends Controller +class LoginController extends Controller { public function show() { diff --git a/app/Http/Controllers/Auth/SignUpController.php b/app/Http/Controllers/Auth/SignUpController.php new file mode 100644 index 0000000..8915ddc --- /dev/null +++ b/app/Http/Controllers/Auth/SignUpController.php @@ -0,0 +1,15 @@ +json([ +// 'domain' => $domain->domain, +// 'records' => $service->buildForDomain($domain), +// ]); +// } + +} diff --git a/app/Http/Controllers/UI/Security/RecoveryCodeDownloadController.php b/app/Http/Controllers/UI/Security/RecoveryCodeDownloadController.php new file mode 100644 index 0000000..6b10edd --- /dev/null +++ b/app/Http/Controllers/UI/Security/RecoveryCodeDownloadController.php @@ -0,0 +1,42 @@ +query('token'); + $payload = Cache::pull("recovery:$token"); // pull = get + forget + + abort_unless($payload, 410); // Gone / abgelaufen + abort_unless($payload['user_id'] === $request->user()->id, 403); + + $email = (string) $request->user()->email; + $now = now()->toDateTimeString(); + + $lines = []; + $lines[] = 'MailWolt – Recovery-Codes'; + $lines[] = "Account: $email"; + $lines[] = "Erzeugt: $now"; + $lines[] = str_repeat('-', 34); + foreach ($payload['codes'] as $code) { + $lines[] = $code; + } + $lines[] = str_repeat('-', 34); + $lines[] = 'Bewahre diese Codes sicher offline auf. Jeder Code ist nur einmal gültig.'; + + $content = implode("\n", $lines); + $filename = 'mailwolt_recovery_codes_' . now()->format('Ymd_His') . '.txt'; + + return Response::streamDownload( + fn () => print($content), + $filename, + ['Content-Type' => 'text/plain; charset=UTF-8'] + ); + } +} diff --git a/app/Http/Controllers/UI/Security/SecurityController.php b/app/Http/Controllers/UI/Security/SecurityController.php new file mode 100644 index 0000000..4e353b7 --- /dev/null +++ b/app/Http/Controllers/UI/Security/SecurityController.php @@ -0,0 +1,40 @@ +update(['status' => $status, 'message' => $message]); + $this->syncCache($task, $ttl); + + // Live-Update an Frontend (Reverb/Echo) + broadcast(new CertProvisionProgress( + taskKey: $task->key, + status : $status, // queued|running|done|failed + message: $message, + mode : $mode // letsencrypt|self-signed + )); + } + + public function handle(): void { $task = SystemTask::where('key', $this->taskKey)->first(); if (!$task) return; + $mode = $this->useLetsEncrypt ? 'letsencrypt' : 'self-signed'; + // running - $task->update(['status' => 'running', 'message' => 'Starte Zertifikat-Provisionierung…']); - $this->syncCache($task); + $this->emit($task, 'running', 'Starte Zertifikat-Provisionierung…', $mode); if ($this->useLetsEncrypt) { - $task->update(['message' => 'Let’s Encrypt wird ausgeführt…']); - $this->syncCache($task); + $this->emit($task, 'running', 'Let’s Encrypt wird ausgeführt…', $mode); $exit = Artisan::call('mailwolt:provision-cert', [ 'domain' => $this->domain, @@ -57,19 +74,22 @@ class ProvisionCertJob implements ShouldQueue if ($exit !== 0) { $out = trim(Artisan::output()); - $task->update(['message' => 'LE fehlgeschlagen: '.($out ?: 'Unbekannter Fehler – Fallback auf Self-Signed…')]); - $this->syncCache($task); + $this->emit( + $task, + 'running', + 'Let’s Encrypt fehlgeschlagen: '.($out ?: 'Unbekannter Fehler') . ' – Fallback auf Self-Signed…', + $mode + ); - // Fallback: Self-Signed + // Fallback → self-signed + $mode = 'self-signed'; $exit = Artisan::call('mailwolt:provision-cert', [ 'domain' => $this->domain, '--self-signed' => true, ]); } } else { - $task->update(['message' => 'Self-Signed wird erstellt…']); - $this->syncCache($task); - + $this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode); $exit = Artisan::call('mailwolt:provision-cert', [ 'domain' => $this->domain, '--self-signed' => true, @@ -79,52 +99,61 @@ class ProvisionCertJob implements ShouldQueue $out = trim(Artisan::output()); if ($exit === 0) { - $task->update(['status' => 'done', 'message' => 'Zertifikat aktiv. '.$out]); - $this->syncCache($task, 10); + $msg = 'Zertifikat erfolgreich erstellt. '.$out; + $this->emit($task, 'done', $msg, $mode, ttl: 10); } else { - $task->update(['status' => 'failed', 'message' => $out ?: 'Zertifikatserstellung fehlgeschlagen.']); - $this->syncCache($task, 30); + $msg = $out ?: 'Zertifikatserstellung fehlgeschlagen.'; + $this->emit($task, 'failed', $msg, $mode, ttl: 30); } } - // public function handle(): void // { // $task = SystemTask::where('key', $this->taskKey)->first(); // if (!$task) return; // +// // running // $task->update(['status' => 'running', 'message' => 'Starte Zertifikat-Provisionierung…']); +// $this->syncCache($task); // // if ($this->useLetsEncrypt) { // $task->update(['message' => 'Let’s Encrypt wird ausgeführt…']); +// $this->syncCache($task); +// // $exit = Artisan::call('mailwolt:provision-cert', [ -// 'domain' => $this->domain, +// 'domain' => $this->domain, // '--email' => $this->email ?? '', // ]); // // if ($exit !== 0) { // $out = trim(Artisan::output()); // $task->update(['message' => 'LE fehlgeschlagen: '.($out ?: 'Unbekannter Fehler – Fallback auf Self-Signed…')]); +// $this->syncCache($task); // // // Fallback: Self-Signed // $exit = Artisan::call('mailwolt:provision-cert', [ -// 'domain' => $this->domain, +// 'domain' => $this->domain, // '--self-signed' => true, // ]); // } // } else { // $task->update(['message' => 'Self-Signed wird erstellt…']); +// $this->syncCache($task); +// // $exit = Artisan::call('mailwolt:provision-cert', [ -// 'domain' => $this->domain, +// 'domain' => $this->domain, // '--self-signed' => true, // ]); // } // // $out = trim(Artisan::output()); +// // if ($exit === 0) { // $task->update(['status' => 'done', 'message' => 'Zertifikat aktiv. '.$out]); +// $this->syncCache($task, 10); // } else { // $task->update(['status' => 'failed', 'message' => $out ?: 'Zertifikatserstellung fehlgeschlagen.']); +// $this->syncCache($task, 30); // } // } } diff --git a/app/Jobs/RunHealthChecks.php b/app/Jobs/RunHealthChecks.php new file mode 100644 index 0000000..dcf1efa --- /dev/null +++ b/app/Jobs/RunHealthChecks.php @@ -0,0 +1,381 @@ +safe(fn() => $this->service('postfix'), ['name'=>'postfix']), + $this->safe(fn() => $this->service('dovecot'), ['name'=>'dovecot']), + $this->safe(fn() => $this->service('rspamd'), ['name'=>'rspamd']), + $this->safe(fn() => $this->tcp('127.0.0.1', 6379), ['name'=>'redis']), + $this->safe(fn() => $this->db(), ['name'=>'db']), +// $this->safe(fn() => $this->queueWorkers(), ['name'=>'queue']), + $this->safe(fn() => $this->tcp('127.0.0.1', 8080), ['name'=>'reverb']), + ]; + + $meta = [ + 'app_version' => config('app.version', app()->version()), + 'pending_migs' => $this->safe(fn() => $this->pendingMigrationsCount(), 0), + 'cert_soon' => $this->safe(fn() => $this->certificatesDue(30), ['count'=>0,'nearest_days'=>null]), + 'disk' => $this->safe(fn() => $this->diskUsage(), ['percent'=>null,'free_gb'=>null]), + 'system' => $this->systemLoad(), + 'updated_at' => now()->toIso8601String(), + ]; + + Cache::put('health:services', array_values($services), 300); + Cache::put('health:meta', $meta, 300); + Cache::put('metrics:queues', [ + 'outgoing' => 19, + 'incoming' => 5, + 'today_ok' => 834, + 'today_err'=> 12, + 'trend' => [ + 'outgoing' => [2,1,0,4,3,5,4,0,0,0], // letzte 10 Zeitfenster + 'incoming' => [1,0,0,1,0,2,1,0,0,0], + 'ok' => [50,62,71,88,92,110,96,120,130,115], + 'err' => [1,0,0,2,1,0,1,3,2,2], + ], + ], 120); + Cache::put('events:recent', $this->safe(fn() => $this->recentAlerts(), []), 300); + } catch (\Throwable $e) { + // Last-resort catch: never allow the job to fail hard + Log::error('RunHealthChecks fatal', ['ex' => $e]); + } + } + + /** Wraps a probe; logs and returns fallback on error */ + protected function safe(callable $fn, $fallback = null) + { + try { + return $fn(); + } catch (\Throwable $e) { + Log::warning('Health probe failed', ['err' => $e->getMessage()]); + return $fallback; + } + } + + protected function service(string $name): array + { + // works even if exit code != 0 + $r = Process::run("systemctl is-active {$name}"); + $raw = trim($r->output() ?: $r->errorOutput()); + return ['name' => $name, 'ok' => ($raw === 'active'), 'raw' => $raw ?: 'unknown']; + } + + protected function tcp(string $host, int $port): array + { + $ok = @fsockopen($host, $port, $errno, $errstr, 0.4) !== false; + return ['name'=>"$host:$port", 'ok'=>$ok, 'raw'=>$ok ? 'open' : ($errstr ?: 'closed')]; + } + + protected function db(): array + { + DB::select('select 1'); // will throw if broken + return ['name'=>'db', 'ok'=>true, 'raw'=>'ok']; + } + + protected function queueWorkers(): array + { + $r = Process::run("systemctl is-active supervisor"); + $raw = trim($r->output() ?: $r->errorOutput()); + return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown']; + } + + protected function diskUsage(): array + { + $total = @disk_total_space('/') ?: 0; + $free = @disk_free_space('/') ?: 0; + if ($total <= 0) return ['percent'=>null,'free_gb'=>null]; + + $used = max(0, $total - $free); + return [ + 'percent' => (int) round($used / $total * 100), + 'free_gb' => (int) round($free / 1024 / 1024 / 1024), + ]; + } + + /** Safe pending migration count for database/migrations */ + protected function pendingMigrationsCount(): int + { + // Compare migration files with repository entries + $files = collect(iterator_to_array( + Finder::create()->files()->in(database_path('migrations'))->name('*.php') + ))->map(fn($f) => pathinfo($f->getFilename(), PATHINFO_FILENAME)); + + $ran = collect(app('migration.repository')->getRan()); + return $files->diff($ran)->count(); + } + + protected function certificatesDue(int $days): array + { + // TODO: hook real cert source + return ['count'=>0, 'nearest_days'=>null]; + } + + protected function queueMetrics(): array + { + // TODO: replace with real counters + return ['outgoing'=>19, 'incoming'=>5, 'today_ok'=>834, 'today_err'=>12]; + } + + protected function recentAlerts(): array + { +// return [ +// ['level'=>'warning','text'=>'TLS handshake retry from 1.2.3.4','at'=>now()->subMinutes(3)->toIso8601String()], +// ['level'=>'error','text'=>'Queue backlog high (outgoing>500)','at'=>now()->subMinutes(12)->toIso8601String()], +// ]; + $events = []; + + // Postfix example (letzte 15 Min) + $r = \Illuminate\Support\Facades\Process::run( + 'journalctl -u postfix --since "15 min ago" -o short-iso -n 200' + ); + foreach (explode("\n", trim($r->output())) as $line) { + if ($line === '') continue; + + // Beispiele für Patterns + if (preg_match('/NOQUEUE: reject/i', $line)) { + $events[] = [ + 'level' => 'warning', + 'text' => 'Postfix reject detected', + 'at' => $this->extractIsoTime($line), + ]; + } + if (preg_match('/timeout|lost connection/i', $line)) { + $events[] = [ + 'level' => 'warning', + 'text' => 'Postfix connection issue', + 'at' => $this->extractIsoTime($line), + ]; + } + } + + // Rspamd example + $r2 = \Illuminate\Support\Facades\Process::run( + 'journalctl -u rspamd --since "15 min ago" -o short-iso -n 200' + ); + foreach (explode("\n", trim($r2->output())) as $line) { + if (preg_match('/greylist|ratelimit/i', $line)) { + $events[] = [ + 'level' => 'info', + 'text' => 'Rspamd rate/greylist notice', + 'at' => $this->extractIsoTime($line), + ]; + } + if (preg_match('/critical|error/i', $line)) { + $events[] = [ + 'level' => 'error', + 'text' => 'Rspamd error', + 'at' => $this->extractIsoTime($line), + ]; + } + } + + // Queue-Backlog Signal (optional) + $q = Cache::get('metrics:queues', []); + if (($q['outgoing'] ?? 0) > 500) { + $events[] = [ + 'level' => 'error', + 'text' => 'Queue backlog high (outgoing>500)', + 'at' => now()->toIso8601String(), + ]; + } + + // Auf 5–10 Einträge begrenzen, nach Zeit sortieren + usort($events, fn($a,$b) => strcmp($b['at'] ?? '', $a['at'] ?? '')); + return array_slice($events, 0, 5); + } + + // Hilfsfunktion: Zeit aus journalctl-Zeile holen (oder now()) + protected function extractIsoTime(string $line): string + { + // journalctl -o short-iso: beginnt mit "2025-10-04T18:33:21+0200 ..." + if (preg_match('/^\s*([0-9T:\-+]+)\s/', $line, $m)) { + try { return \Carbon\Carbon::parse($m[1])->toIso8601String(); } catch (\Throwable $e) {} + } + return now()->toIso8601String(); + } + + protected function systemLoad(): array + { + // Load (1/5/15) + $load = function_exists('sys_getloadavg') ? (array) sys_getloadavg() : [null, null, null]; + + // RAM aus /proc/meminfo + $mem = ['total_gb'=>null,'used_gb'=>null,'free_gb'=>null,'percent'=>null]; + if (is_readable('/proc/meminfo')) { + $info = []; + foreach (file('/proc/meminfo') as $line) { + if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) { + $info[$m[1]] = (int) $m[2]; // kB + } + } + if (!empty($info['MemTotal']) && isset($info['MemAvailable'])) { + $total = $info['MemTotal'] * 1024; + $avail = $info['MemAvailable'] * 1024; + $used = max(0, $total - $avail); + $mem = [ + 'total_gb' => round($total/1024/1024/1024, 1), + 'used_gb' => round($used /1024/1024/1024, 1), + 'free_gb' => round($avail/1024/1024/1024, 1), + 'percent' => $total ? (int) round($used/$total*100) : null, + ]; + } + } + + // Core-Anzahl (für Last-Schätzung & Info) + $cores = $this->cpuCores(); + + // CPU-Prozent (schnelle 200ms-Probe über /proc/stat) + $cpuPercent = $this->cpuPercentSample(200); // kann null sein, wenn nicht lesbar + + // Uptime + $uptime = $this->uptimeInfo(); // ['seconds'=>int|null, 'human'=>string|null] + + return [ + 'cpu_load_1' => $load[0] ?? null, + 'cpu_load_5' => $load[1] ?? null, + 'cpu_load_15' => $load[2] ?? null, + + // hilft der Livewire-Klasse beim Schätzen (falls cpu_percent null ist) + 'cores' => $cores, + + // direkt nutzbar – wird bevorzugt angezeigt + 'cpu_percent' => $cpuPercent, + + // RAM Block (wie bisher, nur vollständiger) + 'ram' => $mem, + + // Uptime in zwei Formen + 'uptime_seconds'=> $uptime['seconds'], + 'uptime_human' => $uptime['human'], + ]; + } + + /** Anzahl CPU-Kerne robust ermitteln */ + protected function cpuCores(): ?int + { + // 1) nproc + $n = @trim((string) @shell_exec('nproc 2>/dev/null')); + if (ctype_digit($n) && (int)$n > 0) return (int)$n; + + // 2) /proc/cpuinfo + if (is_readable('/proc/cpuinfo')) { + $cnt = preg_match_all('/^processor\s*:\s*\d+/mi', file_get_contents('/proc/cpuinfo')); + if ($cnt > 0) return $cnt; + } + return null; + } + + /** + * CPU-Auslastung in % per 2-Punkt-Messung über /proc/stat. + * $ms: Messdauer in Millisekunden. + */ + protected function cpuPercentSample(int $ms = 200): ?int + { + $a = $this->readProcStatTotals(); + if (!$a) return null; + usleep(max(1, $ms) * 1000); + $b = $this->readProcStatTotals(); + if (!$b) return null; + + $idleDelta = $b['idle'] - $a['idle']; + $totalDelta = $b['total'] - $a['total']; + if ($totalDelta <= 0) return null; + + $usage = 100 * (1 - ($idleDelta / $totalDelta)); + return (int) round(max(0, min(100, $usage))); + } + + /** Totals aus /proc/stat (user,nice,system,idle,iowait,irq,softirq,steal,guest,guest_nice) */ + protected function readProcStatTotals(): ?array + { + if (!is_readable('/proc/stat')) return null; + $line = strtok(file('/proc/stat')[0] ?? '', "\n"); + if (!str_starts_with($line, 'cpu ')) return null; + + $parts = preg_split('/\s+/', trim($line)); + // cpu user nice system idle iowait irq softirq steal guest guest_nice + $vals = array_map('floatval', array_slice($parts, 1)); + $idle = ($vals[3] ?? 0) + ($vals[4] ?? 0); + $total = array_sum($vals); + return ['idle' => $idle, 'total' => $total]; + } + + /** Uptime aus /proc/uptime: Sekunden + menschenlesbar */ + protected function uptimeInfo(): array + { + $sec = null; + if (is_readable('/proc/uptime')) { + $first = trim(explode(' ', trim(file_get_contents('/proc/uptime')))[0] ?? ''); + if (is_numeric($first)) $sec = (int) round((float) $first); + } + return [ + 'seconds' => $sec, + 'human' => $sec !== null ? $this->fmtSecondsHuman($sec) : null, + ]; + } + + protected function fmtSecondsHuman(int $s): string + { + $d = intdiv($s, 86400); $s %= 86400; + $h = intdiv($s, 3600); $s %= 3600; + $m = intdiv($s, 60); + if ($d > 0) return "{$d}d {$h}h"; + if ($h > 0) return "{$h}h {$m}m"; + return "{$m}m"; + } + +// protected function systemLoad(): array +// { +// // 1, 5, 15 Minuten Load averages +// $load = function_exists('sys_getloadavg') ? sys_getloadavg() : [null,null,null]; +// +// // RAM aus /proc/meminfo (Linux) +// $mem = ['total'=>null,'free'=>null,'used'=>null,'percent'=>null]; +// if (is_readable('/proc/meminfo')) { +// $info = []; +// foreach (file('/proc/meminfo') as $line) { +// if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) { +// $info[$m[1]] = (int)$m[2]; // kB +// } +// } +// if (!empty($info['MemTotal']) && !empty($info['MemAvailable'])) { +// $total = $info['MemTotal'] * 1024; +// $avail = $info['MemAvailable'] * 1024; +// $used = $total - $avail; +// $mem = [ +// 'total_gb' => round($total/1024/1024/1024,1), +// 'used_gb' => round($used/1024/1024/1024,1), +// 'free_gb' => round($avail/1024/1024/1024,1), +// 'percent' => $total ? round($used/$total*100) : null, +// ]; +// } +// } +// +// return [ +// 'cpu_load_1' => $load[0], +// 'cpu_load_5' => $load[1], +// 'cpu_load_15' => $load[2], +// 'ram' => $mem, +// ]; +// } +} diff --git a/app/Jobs/SimulateCertIssue.php b/app/Jobs/SimulateCertIssue.php new file mode 100644 index 0000000..8cf0ee1 --- /dev/null +++ b/app/Jobs/SimulateCertIssue.php @@ -0,0 +1,82 @@ +taskKey, + state: 'queued', + message: 'Erstellung gestartet…', + progress: 5, + meta: ['domain' => $this->domain, 'mode' => 'self-signed'] + )); + sleep(1); + + // running – CSR + event(new CertStatusUpdated( + id: $this->taskKey, + state: 'running', + message: 'CSR wird erzeugt…', + progress: 20 + )); + sleep(1); + + // running – Schlüssel + event(new CertStatusUpdated( + id: $this->taskKey, + state: 'running', + message: 'Schlüssel wird erstellt…', + progress: 55 + )); + sleep(1); + + // running – signieren + event(new CertStatusUpdated( + id: $this->taskKey, + state: 'running', + message: 'Self-Signed wird signiert…', + progress: 85 + )); + sleep(1); + + // done (FINAL) + event(new CertStatusUpdated( + id: $this->taskKey, + state: 'done', + message: "Zertifikat für {$this->domain} erstellt.", + progress: 100, + meta: ['final' => true, 'domain' => $this->domain] + )); + + // --- Aufräumen (verhindert doppelten Toast nach Reload) --- + // 1) Task-Detail entfernen + Cache::store('redis')->forget($this->taskKey); + // (Falls du zusätzlich unter "mailwolt_cache:{$this->taskKey}" speicherst, dann auch das:) + Cache::store('redis')->forget("mailwolt_cache:{$this->taskKey}"); + + // 2) User-Task-Set bereinigen (so hast du es gespeichert) + if (auth()->id()) { + Redis::srem('user:'.auth()->id().':tasks', $this->taskKey); + } + } +} diff --git a/app/Livewire/Auth/LoginForm.php b/app/Livewire/Auth/LoginForm.php index 9a019a0..c7913ba 100644 --- a/app/Livewire/Auth/LoginForm.php +++ b/app/Livewire/Auth/LoginForm.php @@ -11,6 +11,8 @@ class LoginForm extends Component public string $name = ''; public string $password = ''; public ?string $error = null; + public bool $show = false; + public function login() { @@ -24,7 +26,6 @@ class LoginForm extends Component $field = filter_var($this->name, FILTER_VALIDATE_EMAIL) ? 'email' : 'username'; - // Login-Versuch if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) { request()->session()->regenerate(); return redirect()->intended(route('setup.wizard')) ; diff --git a/app/Livewire/Auth/SignupForm.php b/app/Livewire/Auth/SignupForm.php new file mode 100644 index 0000000..d50b752 --- /dev/null +++ b/app/Livewire/Auth/SignupForm.php @@ -0,0 +1,62 @@ + ['required','string','max:120'], + 'email' => ['required','string','lowercase','email','max:190','unique:users,email'], + 'password' => ['required', 'confirmed', Password::min(4)], + 'accept' => ['accepted'], + ]; + } + + public function register() + { + $this->validate(); + $isFirstUser = User::count() === 0; + + User::create([ + 'name' => $this->name, + 'email' => $this->email, + 'password' => Hash::make($this->password), + 'role' => Setting::signupAllowed() ? Role::Admin : Role::Member, + ]); + + if ($isFirstUser) { + Setting::updateOrCreate(['key' => 'signup_enabled'], ['value' => '0']); + } + + $this->reset(['name','email','password','password_confirmation', 'accept']); + $this->dispatch('toast', + state: 'done', + badge: 'Signup', + domain: 'Registrierung erfolgreich', + message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. Zum Login', + duration: -1, + ); + } + + public function render() + { + return view('livewire.auth.signup-form'); + } +} diff --git a/app/Livewire/PingButton.php b/app/Livewire/PingButton.php new file mode 100644 index 0000000..6cefedb --- /dev/null +++ b/app/Livewire/PingButton.php @@ -0,0 +1,85 @@ +id() +use Livewire\Component; + +class PingButton extends Component +{ + public function ping() + { + event(new CertPing('Ping von Livewire-Button 🚀')); + + // kleines Feedback + $this->dispatch('toast', [ + 'state' => 'done', + 'badge' => 'Broadcast', + 'domain' => 'Event gesendet', + 'message' => 'Sieh in der Browser-Konsole nach.', + 'duration' => 3000, + ]); + } + + public function simulateSelfSigned(string $domain = '10.10.70.58') + { + $taskKey = 'task:issue-cert:'.$domain; + + // Optionaler „Start“-Toast vor Redirect + $this->dispatch('toast', state: 'queued', badge: 'ZERTIFIKAT', + domain: $domain, message: 'Erstellung gestartet…', pos: 'bottom-right', duration: -1 + ); + + \App\Jobs\SimulateCertIssue::dispatch($domain, $taskKey); + +// return redirect()->route('dashboard'); + } + + +// public function simulateSelfSigned(string $domain = '10.10.70.58') +// { +// $taskKey = 'issue-cert:' . $domain; +// $userId = Auth::id() ?? 0; +// +// // (optional aber nice): Sofort in Redis persistieren + WS feuern, +// // damit der Toast schon vor dem ersten Job-Schritt sichtbar ist. +// ToastBus::put($userId, $taskKey, [ +// 'title' => 'Zertifikat', +// 'badge' => 'Self-signed', +// 'message' => 'Erstellung gestartet…', +// 'state' => 'queued', +// 'progress' => 5, +// ]); +// +// // zusätzlich lokales UI-Feedback (falls du es magst) +// $this->dispatch('toast', [ +// 'state' => 'queued', +// 'badge' => 'Zertifikat', +// 'domain' => $domain, +// 'message' => 'Erstellung gestartet…', +// 'pos' => 'bottom-right', +// 'duration' => -1, // offen lassen, bis "done/failed" kommt +// ]); +// +// // JOB mit neuer Signatur starten +// SimulateCertIssue::dispatch( +// userId: $userId, +// domain: $domain, +// taskKey: $taskKey +// ); +// +// // Dashboard kann per "Initial-Fetch" sofort den Task laden +// session()->flash('task_key', $taskKey); +// +// // weiterleiten; die Echtzeit-Updates laufen unabhängig per WS +// return redirect()->route('dashboard'); +// } + + public function render() + { + return view('livewire.ping-button'); + } +} diff --git a/app/Livewire/Ui/Dashboard/DomainsPanel.php b/app/Livewire/Ui/Dashboard/DomainsPanel.php new file mode 100644 index 0000000..78a84a7 --- /dev/null +++ b/app/Livewire/Ui/Dashboard/DomainsPanel.php @@ -0,0 +1,69 @@ +'mail.example.com', 'status'=>'1:249 ausstehend', 'icon'=>'ph-envelope-simple', 'cert_days'=>8], + ['name'=>'beispiel.de', 'status'=>'834 gesendet', 'icon'=>'ph-globe', 'cert_days'=>24], + ['name'=>'user.test', 'status'=>'Ohne Aktivität', 'icon'=>'ph-at', 'cert_days'=>3], + ]; + + protected function certBadge(?int $days): array + { + if ($days === null) { + return [ + 'class' => 'bg-white/8 text-white/50 border-white/10', + 'icon' => 'ph-shield-warning-duotone', + 'label' => '—', + 'title' => 'Gültigkeit unbekannt', + ]; + } + + if ($days <= 5) { + return [ + 'class' => 'bg-rose-500/10 text-rose-300 border-rose-400/30', + 'icon' => 'ph-shield-x-duotone', + 'label' => $days.'d', + 'title' => "Zertifikat sehr kritisch (≤5 Tage)", + ]; + } + + if ($days <= 20) { + return [ + 'class' => 'bg-amber-400/10 text-amber-300 border-amber-300/30', + 'icon' => 'ph-shield-warning-duotone', + 'label' => $days.'d', + 'title' => "Zertifikat bald fällig (≤20 Tage)", + ]; + } + + return [ + 'class' => 'bg-emerald-500/10 text-emerald-300 border-emerald-400/30', + 'icon' => 'ph-shield-check-duotone', + 'label' => $days.'d', + 'title' => "Zertifikat ok (>20 Tage)", + ]; + } + + public function getDomainsWithBadgesProperty(): array + { + return array_map(function ($d) { + $b = $this->certBadge($d['cert_days'] ?? null); + return $d + [ + 'cert_class' => $b['class'], + 'cert_icon' => $b['icon'], + 'cert_label' => $b['label'], + 'cert_title' => $b['title'], + ]; + }, $this->domains); + } + + public function render() + { + return view('livewire.ui.dashboard.domains-panel'); + } +} diff --git a/app/Livewire/Ui/Dashboard/HealthCard.php b/app/Livewire/Ui/Dashboard/HealthCard.php new file mode 100644 index 0000000..d3b8415 --- /dev/null +++ b/app/Livewire/Ui/Dashboard/HealthCard.php @@ -0,0 +1,292 @@ +'–%','label'=>'Speicher belegt']; + + // Anzeige oben + public ?string $ramSummary = null; + public ?string $loadText = null; + public ?string $uptimeText = null; + public ?int $cpuPercent = null; + public ?int $ramPercent = null; + public ?string $updatedAtHuman = null; + + public function mount(): void + { + $this->loadData(); + } + + public function loadData(): void + { + $this->services = Cache::get('health:services', []); + $this->meta = Cache::get('health:meta', []); + + $this->hydrateSystem(); + $this->hydrateDisk(); + $this->hydrateUpdatedAt(); + $this->decorateServicesCompact(); + $this->decorateDisk(); + } + + public function render() + { + return view('livewire.ui.dashboard.health-card'); + } + + /* ---------------- Aufbereitung ---------------- */ + + protected function hydrateSystem(): void + { + $sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : []; + + // RAM % + $this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory'])); + if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) { + $this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent'])); + } + + // RAM Summary "used / total" + $used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB'])); + $tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB'])); + $this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null; + + // Load (1/5/15) + $load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1'])); + $load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5'])); + $load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15'])); + + $loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']); + if (is_array($loadMixed)) { + $vals = array_values($loadMixed); + $load1 ??= $this->numFloat($vals[0] ?? null); + $load5 ??= $this->numFloat($vals[1] ?? null); + $load15 ??= $this->numFloat($vals[2] ?? null); + } + $this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null); + + // CPU % + $this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage'])); + if ($this->cpuPercent === null && $load1 !== null) { + $cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1; + $this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100))); + } + + // Uptime + $this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']); + if (!$this->uptimeText) { + $uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s'])); + if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec); + } + + // Segment-Balken + $this->cpuSeg = $this->buildSegments($this->cpuPercent); + $this->ramSeg = $this->buildSegments($this->ramPercent); + + // Load: Dots (1/5/15 relativ zu Kernen) + $cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1)); + $ratio1 = $load1 !== null ? $load1 / $cores : null; + $ratio5 = $load5 !== null ? $load5 / $cores : null; + $ratio15 = $load15 !== null ? $load15 / $cores : null; + + $this->loadBadgeText = $this->loadText ?? '–'; + $this->loadDots = [ + ['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)], + ['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)], + ['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)], + ]; + + // Uptime Chips + $chips = []; + if ($this->uptimeText) { + $d = $h = $m = null; + if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1]; + if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1]; + if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1]; + if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage']; + if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden']; + if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten']; + } + $this->uptimeChips = $chips ?: [['v'=>'–','u'=>'']]; + } + + protected function hydrateDisk(): void + { + $disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : []; + + $this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage'])); + $this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free'])); + + // total/used berechnen, falls nicht geliefert + if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) { + $p = max(0, min(100, $this->diskPercent)) / 100; + $estTotal = (int) round($this->diskFreeGb / (1 - $p)); + $this->diskTotalGb = $estTotal > 0 ? $estTotal : null; + } + if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) { + $u = $this->diskTotalGb - $this->diskFreeGb; + $this->diskUsedGb = $u >= 0 ? $u : null; + } + } + + protected function hydrateUpdatedAt(): void + { + $updated = $this->meta['updated_at'] ?? null; + try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–'; + } catch (\Throwable) { $this->updatedAtHuman = '–'; } + } + + protected function decorateServicesCompact(): void + { + $nameMap = [ + 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'], + 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'], + 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'], + 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'], + '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'], + '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'], + ]; + + $this->servicesCompact = collect($this->services) + ->map(function ($srv) use ($nameMap) { + $key = (string)($srv['name'] ?? ''); + $ok = (bool) ($srv['ok'] ?? false); + $label = $nameMap[$key]['label'] ?? ucfirst($key); + $hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : ''); + + return [ + 'label' => $label, + 'hint' => $hint, + 'ok' => $ok, + 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400', + 'pillText' => $ok ? 'ok' : 'down', + 'pillClass' => $ok + ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' + : 'text-rose-300 border-rose-400/30 bg-rose-500/10', + ]; + }) + ->values() + ->all(); + } + + protected function decorateDisk(): void + { + $percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0))); + $this->diskCenterText = [ + 'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '–%', + 'label' => 'Speicher belegt', + ]; + + $count = 48; + $activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0; + $step = 360 / $count; + + $activeClass = match (true) { + $percent >= 90 => 'bg-rose-400', + $percent >= 70 => 'bg-amber-300', + default => 'bg-emerald-400', + }; + + $this->diskSegments = []; + for ($i = 0; $i < $count; $i++) { + $angle = ($i * $step) - 90; // Start bei 12 Uhr + $this->diskSegments[] = [ + 'angle' => $angle, + 'class' => $i < $activeN ? $activeClass : 'bg-white/16', + ]; + } + } + + /* ---------------- Helpers ---------------- */ + + protected function pick(array $arr, array $keys) + { + foreach ($keys as $k) { + if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') { + return $arr[$k]; + } + } + return null; + } + + protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; } + protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; } + + protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string + { + if ($l1 !== null || $l5 !== null || $l15 !== null) { + $fmt = fn($x) => $x === null ? '–' : number_format($x, 2); + return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}"; + } + return $fallback ?: null; + } + + protected function secondsToHuman(int $s): string + { + $d = intdiv($s, 86400); $s %= 86400; + $h = intdiv($s, 3600); $s %= 3600; + $m = intdiv($s, 60); + if ($d > 0) return "{$d}d {$h}h"; + if ($h > 0) return "{$h}h {$m}m"; + return "{$m}m"; + } + + protected function toneByPercent(?int $p): string { + if ($p === null) return 'white'; + if ($p >= 90) return 'rose'; + if ($p >= 70) return 'amber'; + return 'emerald'; + } + + protected function buildSegments(?int $percent): array { + $n = max(6, $this->barSegments); + $filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0; + $tone = $this->toneByPercent($percent); + $fillCls = match($tone) { + 'rose' => 'bg-rose-500/80', + 'amber' => 'bg-amber-400/80', + 'emerald'=> 'bg-emerald-500/80', + default => 'bg-white/20', + }; + return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1)); + } + + protected function loadDotClass(?float $ratio): string { + if ($ratio === null) return 'bg-white/25'; + if ($ratio >= 0.9) return 'bg-rose-500'; + if ($ratio >= 0.7) return 'bg-amber-400'; + return 'bg-emerald-400'; + } +} diff --git a/app/Livewire/Ui/Dashboard/RecentLoginsTable.php b/app/Livewire/Ui/Dashboard/RecentLoginsTable.php new file mode 100644 index 0000000..fbfb95a --- /dev/null +++ b/app/Livewire/Ui/Dashboard/RecentLoginsTable.php @@ -0,0 +1,19 @@ +'max.mustermann','ip'=>'182.163.1.123','time'=>'03.10.2023, 13:37'], + ['user'=>'sabine','ip'=>'10.4.23.8','time'=>'03.10.2023, 12:03'], + ['user'=>'admin','ip'=>'84.122.16.46','time'=>'03.10.2023, 11:34'], + ]; + + public function render() + { + return view('livewire.ui.dashboard.recent-logins-table'); + } +} diff --git a/app/Livewire/Ui/Dashboard/TopBar.php b/app/Livewire/Ui/Dashboard/TopBar.php new file mode 100644 index 0000000..3ab29e4 --- /dev/null +++ b/app/Livewire/Ui/Dashboard/TopBar.php @@ -0,0 +1,68 @@ + offene Migrationen + public string $appVersion = ''; + public bool $hasUpdate = false; // z.B. wenn remote Version > local (optional) + + // Klassen für Farben (im Blade nur verwenden) + public string $domainsBadgeClass = 'bg-emerald-500/20 text-emerald-300'; + public string $warningsBadgeClass = 'bg-emerald-500/20 text-emerald-300'; + public string $updatesBadgeClass = 'bg-emerald-500/20 text-emerald-300'; + + public ?string $ipv4 = null; + public ?string $ipv6 = null; + + + public function mount(): void + { + // Domains + Zertifikate (passe an deinen Storage an) + $this->domainsCount = Domain::count(); // oder aus Cache/Repo + + // Beispiel: Domain::select('cert_expires_at','cert_issuer')... + // -> hier simple Annahme: im Cache 'domains:certs' liegt [{domain, days_left, type}] + $certs = Cache::get('domains:certs', []); // Struktur: [['days_left'=>int,'type'=>'letsencrypt|selfsigned'], ...] + $this->certExpiring = collect($certs)->where('days_left', '<=', 30)->count(); + + // Warnungen + $events = Cache::get('events:recent', []); + $this->warningsCount = is_array($events) ? min(99, count($events)) : 0; + + // Updates / Migrations + $meta = Cache::get('health:meta', []); + $this->pendingMigs = (int)($meta['pending_migs'] ?? 0); + $this->appVersion = (string)($meta['app_version'] ?? ''); + $this->hasUpdate = $this->pendingMigs > 0; // oder echte Versionprüfung + + // IPv4 + $this->ipv4 = trim(shell_exec("hostname -I | awk '{print $1}'")) ?: '–'; + + // IPv6 (optional) + $this->ipv6 = trim(shell_exec("hostname -I | awk '{print $2}'")) ?: '–'; + + // Farben berechnen (nur einmal hier) + $this->domainsBadgeClass = $this->certExpiring === 0 ? 'bg-emerald-500/20 text-emerald-300' + : ($this->certExpiring <= 2 ? 'bg-amber-400/20 text-amber-300' + : 'bg-rose-500/20 text-rose-300'); + $this->warningsBadgeClass = $this->warningsCount > 0 ? 'bg-rose-500/20 text-rose-300' + : 'bg-emerald-500/20 text-emerald-300'; + $this->updatesBadgeClass = $this->hasUpdate ? 'bg-amber-400/20 text-amber-300' + : 'bg-emerald-500/20 text-emerald-300'; + } + + public function render() + { + return view('livewire.ui.dashboard.top-bar'); + } +} diff --git a/app/Livewire/Ui/Domain/DomainDnsList.php b/app/Livewire/Ui/Domain/DomainDnsList.php new file mode 100644 index 0000000..1450d09 --- /dev/null +++ b/app/Livewire/Ui/Domain/DomainDnsList.php @@ -0,0 +1,34 @@ +domain = Domain::findOrFail($domainId); +// $this->records = app(DnsRecordService::class)->buildForDomain($this->domain); +// } + + public function openDnsModal(int $domainId): void + { + // wire-elements-modal: Modal öffnen und Parameter übergeben + $this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: [ + 'domainId' => $domainId, + ]); + } + + public function render() + { + return view('livewire.ui.domain.domain-dns-list', [ + 'domains' => Domain::orderBy('domain')->get(), + ]); + } +} diff --git a/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php b/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php new file mode 100644 index 0000000..3c5b518 --- /dev/null +++ b/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php @@ -0,0 +1,232 @@ +> */ + public array $static = []; + /** @var array> */ + public array $dynamic = []; + + public static function modalMaxWidth(): string { return '6xl'; } + + public function mount(int $domainId): void + { + $this->recordColors = [ + 'A' => 'bg-cyan-500/20 text-cyan-300', + 'AAAA' => 'bg-blue-500/20 text-blue-300', + 'MX' => 'bg-emerald-500/20 text-emerald-300', + 'CNAME' => 'bg-indigo-500/20 text-indigo-300', + 'PTR' => 'bg-amber-500/20 text-amber-300', + 'TXT' => 'bg-violet-500/20 text-violet-300', + 'SRV' => 'bg-rose-500/20 text-rose-300', + 'TLSA' => 'bg-red-500/20 text-red-300', + ]; + + $d = Domain::findOrFail($domainId); + $this->domainName = $d->domain; + + $ipv4 = $this->detectIPv4(); + $ipv6 = $this->detectIPv6(); // kann null sein + $this->base = env('BASE_DOMAIN', 'example.com'); + $mta = env('MTA_SUB', 'mx').'.'.$this->base; // mx.example.com + + // --- Statische Infrastruktur (für alle Domains gleich) --- + $this->static = [ + ['type'=>'A','name'=>$mta,'value'=>$ipv4], + ['type'=>'PTR','name'=>$this->ptrFromIPv4($ipv4),'value'=>$mta], + ]; + if ($ipv6) { + $this->static[] = ['type'=>'AAAA','name'=>$mta,'value'=>$ipv6]; + $this->static[] = ['type'=>'PTR', 'name'=>$this->ptrFromIPv6($ipv6),'value'=>$mta]; + } + if ($tlsa = config('mailwolt.tlsa')) { + $this->static[] = ['type'=>'TLSA','name'=>"_25._tcp.$mta",'value'=>$tlsa]; + } + $this->static[] = ['type'=>'MX','name'=>$this->domainName,'value'=>"10 $mta."]; + + // --- Domain-spezifisch --- + $spf = 'v=spf1 mx a -all'; + $dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100"; + + $dkim = DB::table('dkim_keys') + ->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first(); + $selector = $dkim ? $dkim->selector : 'mwl1'; + $dkimHost = "{$selector}._domainkey.{$this->domainName}"; + $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt),'v=') + ? 'v=DKIM1; k=rsa; p='.$dkim->public_key_txt + : ($dkim->public_key_txt ?? 'v=DKIM1; k=rsa; p='); + + $this->dynamic = [ + ['type'=>'TXT','name'=>$this->domainName, 'value'=>$spf, 'helpLabel'=>'SPF Record Syntax','helpUrl'=>'http://www.open-spf.org/SPF_Record_Syntax/'], + ['type'=>'TXT','name'=>"_dmarc.{$this->domainName}", 'value'=>$dmarc, 'helpLabel'=>'DMARC Assistant','helpUrl'=>'https://www.kitterman.com/dmarc/assistant.html'], + ['type'=>'TXT','name'=>$dkimHost, 'value'=>$dkimTxt, 'helpLabel'=>'DKIM Inspector','helpUrl'=>'https://dkimvalidator.com/'], + ]; + } + + private function detectIPv4(): string + { + // robust & ohne env + $out = @shell_exec("ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'"); + $ip = trim((string)$out); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip; + $ip = trim($_SERVER['SERVER_ADDR'] ?? ''); + return $ip ?: '203.0.113.10'; // Fallback Demo + } + + private function detectIPv6(): ?string + { + $out = @shell_exec("ip -6 route get 2001:4860:4860::8888 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'"); + $ip = trim((string)$out); + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? $ip : null; + } + + private function ptrFromIPv4(string $ip): string + { + $p = array_reverse(explode('.', $ip)); + return implode('.', $p).'.in-addr.arpa'; + } + private function ptrFromIPv6(string $ip): string + { + $expanded = strtolower(inet_ntop(inet_pton($ip))); + $hex = str_replace(':', '', $expanded); + return implode('.', array_reverse(str_split($hex))).'.ip6.arpa'; + } + + public function render() + { + return view('livewire.ui.domain.modal.domain-dns-modal'); + } + +// public int $domainId; +// public Domain $domain; +// +// public array $records = []; +// +// public function mount(int $domainId): void +// { +// $this->domainId = $domainId; +// $this->domain = Domain::findOrFail($domainId); +// +// // Placeholder-Werte, sofern du sie anderswo speicherst gern ersetzen: +// $serverIp = config('app.server_ip', 'DEINE.SERVER.IP'); +// $mxHost = config('mailwolt.mx_fqdn', 'mx.' . $this->domain->domain); +// $selector = optional( +// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first() +// )->selector ?? 'mwl1'; +// +// $dkimTxt = optional( +// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first() +// )->public_key_txt ?? 'DKIM_PUBLIC_KEY'; +// +// $this->records = [ +// [ +// 'type' => 'A', +// 'host' => $this->domain->domain, +// 'value' => $serverIp, +// 'ttl' => 3600, +// ], +// [ +// 'type' => 'MX', +// 'host' => $this->domain->domain, +// 'value' => "10 {$mxHost}.", +// 'ttl' => 3600, +// ], +// [ +// 'type' => 'TXT', +// 'host' => $this->domain->domain, +// 'value' => 'v=spf1 mx a -all', +// 'ttl' => 3600, +// ], +// [ +// 'type' => 'TXT', +// 'host' => "{$selector}._domainkey." . $this->domain->domain, +// // komplette, fertige TXT-Payload (aus deiner dkim_keys.public_key_txt Spalte) +// 'value' => "v=DKIM1; k=rsa; p={$dkimTxt}", +// 'ttl' => 3600, +// ], +// [ +// 'type' => 'TXT', +// 'host' => "_dmarc." . $this->domain->domain, +// 'value' => "v=DMARC1; p=none; rua=mailto:dmarc@" . $this->domain->domain . "; pct=100", +// 'ttl' => 3600, +// ], +// ]; +// } +// +// public static function modalMaxWidth(): string +// { +// return '4xl'; // schön breit +// } +// +// public function render() +// { +// return view('livewire.ui.domain.modal.domain-dns-modal'); +// } + +// public Domain $domain; +// +// public static function modalMaxWidth(): string +// { +// return '3xl'; +// } +// +// public function mount(int $domainId): void +// { +// $this->domain = Domain::with(['dkimKeys' /* falls Relationen existieren */])->findOrFail($domainId); +// } +// +// public function render() +// { +// // Hier kannst du die Records vorbereiten (SPF/DMARC/DKIM) +// $records = [ +// [ +// 'type' => 'TXT', +// 'host' => $this->domain->domain, +// 'value'=> $this->domain->spf_record ?? 'v=spf1 mx a -all', +// 'ttl' => 3600, +// ], +// [ +// 'type' => 'TXT', +// 'host' => '_dmarc.' . $this->domain->domain, +// 'value'=> $this->domain->dmarc_record ?? 'v=DMARC1; p=none; rua=mailto:dmarc@' . $this->domain->domain . '; pct=100', +// 'ttl' => 3600, +// ], +// // DKIM (falls vorhanden) +// // Beispiel: nimm den aktiven Key oder den ersten +// ]; +// +// $dkim = optional($this->domain->dkimKeys()->where('is_active', true)->first() ?? $this->domain->dkimKeys()->first()); +// if ($dkim && $dkim->selector) { +// $records[] = [ +// 'type' => 'TXT', +// 'host' => "{$dkim->selector}._domainkey." . $this->domain->domain, +// 'value'=> "v=DKIM1; k=rsa; p={$dkim->public_key_txt}", +// 'ttl' => 3600, +// ]; +// } +// +// return view('livewire.ui.domain.modal.domain-dns-modal', [ +// 'records' => $records, +// ]); +// } + +// public function render() +// { +// return view('livewire.ui.domain.modal.domain-dns-modal'); +// } +} diff --git a/app/Livewire/Ui/Security/AccountSecurityForm.php b/app/Livewire/Ui/Security/AccountSecurityForm.php new file mode 100644 index 0000000..f99c9da --- /dev/null +++ b/app/Livewire/Ui/Security/AccountSecurityForm.php @@ -0,0 +1,194 @@ + 'onTotpEnabled', + 'totp-disabled' => 'onTotpDisabled', + 'email2fa-enabled' => 'onEmail2faEnabled', + 'email2fa-disabled' => 'onEmail2faDisabled', + ]; + + public function mount(): void + { + $u = Auth::user(); + + $this->name = (string)($u->name ?? ''); + $this->username = (string)($u->username ?? ''); + $this->email = (string)($u->email ?? ''); + $this->maskedEmail = $this->maskEmail($u->email ?? ''); + $this->emailInput = (string)($u->email ?? ''); + + + $this->totpActive = (bool) optional($u->twoFactorMethod('totp'))->enabled; + $this->email2faActive = (bool) optional($u->twoFactorMethod('email'))->enabled; + $this->userHas2fa = $u->twoFactorEnabled(); + } + + + protected function rules(): array + { + $userId = Auth::id(); + + return [ + 'require2fa' => ['boolean'], + 'allowTotp' => ['boolean'], + 'allowMail' => ['boolean'], + + 'username' => ['required','string','min:3','max:80'], + 'email' => [ + 'required','email', Rule::unique('users','email')->ignore($userId) + ], + + 'current_password' => ['nullable','string'], + 'new_password' => ['nullable', Password::min(10)->mixedCase()->numbers()], + 'new_password_confirmation' => ['same:new_password'], + + 'email_current' => ['required','email','in:'.(auth()->user()->email ?? '')], + 'email_new' => ['required','email','different:email_current','max:255','unique:users,email'], + ]; + } + + /* ===== Aktionen ===== */ + + public function save2faPolicy(): void + { + $this->validate([ + 'require2fa' => ['boolean'], + 'allowTotp' => ['boolean'], + 'allowMail' => ['boolean'], + ]); + + // TODO: In einer Settings-Tabelle persistieren + $this->dispatch('toast', body: '2FA-Richtlinie gespeichert.'); + } + +// public function toggleUser2fa(): void +// { +// // “Globaler” Toggle – wenn nichts aktiv, öffnet der Nutzer die Modals. +// // Hier nur eine UX-Rückmeldung: +// if (! $this->userHas2fa) { +// $this->dispatch('toast', body: 'Wähle und richte zuerst eine Methode ein (TOTP oder E-Mail).'); +// return; +// } +// +// // Wenn mindestens 1 Methode aktiv ist → komplett aus +// $u = Auth::user(); +// $u->twoFactorMethods()->update(['enabled' => false, 'confirmed_at' => null, 'secret' => null]); +// $this->totpActive = $this->email2faActive = $this->userHas2fa = false; +// +// $this->dispatch('toast', body: '2FA deaktiviert.'); +// } + + + protected function maskEmail(string $email): string { + if (!str_contains($email, '@')) return $email; + [$l, $d] = explode('@', $email, 2); + $l = strlen($l) <= 2 ? substr($l, 0, 1).'…' : substr($l,0,1).str_repeat('•', max(1, strlen($l)-3)).substr($l,-2); + $d = preg_replace('/(^.).*(\..{1,4}$)/', '$1…$2', $d); + return "{$l}@{$d}"; + } + + public function saveProfile(): void + { + $this->validate([ + 'username' => ['required','string','min:3','max:80'], + 'email' => ['required','email', Rule::unique('users','email')->ignore(Auth::id())], + ]); + + $u = Auth::user(); + $u->name = $this->username; + $u->email = $this->email; + $u->save(); + + $this->dispatch('toast', body: 'Profil gespeichert.'); + } + + public function changePassword(): void + { + if ($this->new_password === '') { + $this->dispatch('toast', body: 'Kein neues Passwort eingegeben.'); + return; + } + + $this->validate([ + 'current_password' => ['required','string'], + 'new_password' => ['required', Password::min(10)->mixedCase()->numbers()], + 'new_password_confirmation' => ['required','same:new_password'], + ]); + + $u = Auth::user(); + if (! Hash::check($this->current_password, $u->password)) { + $this->addError('current_password','Aktuelles Passwort ist falsch.'); + return; + } + + $u->password = Hash::make($this->new_password); + $u->save(); + + $this->reset(['current_password','new_password','new_password_confirmation']); + $this->dispatch('toast', body: 'Passwort aktualisiert.'); + } + + + public function changeEmail(): void + { + $this->validate(['email_current','email_new']); + + $user = auth()->user(); + $old = $user->email; + + // 1) neue E-Mail in „pending“ Tabelle/Spalte ablegen (z. B. users.email_pending + token) + // 2) Verifizierungs-Mail an $this->email_new senden (mit Token) + // 3) Anzeigen/Toast + $this->dispatch('toast', body: 'Bestätigungslink an die neue E-Mail gesendet.'); + + // Felder leeren + $this->reset(['email_current', 'email_new', 'email_new_confirmation']); + } + + public function onTotpEnabled() { $this->totpActive = true; $this->userHas2fa = true; } + public function onTotpDisabled() { $this->totpActive = false; $this->userHas2fa = $this->email2faActive; } + public function onEmail2faEnabled() { $this->email2faActive = true; $this->userHas2fa = true; } + public function onEmail2faDisabled(){ $this->email2faActive = false; $this->userHas2fa = $this->totpActive; } + + public function render() + { + return view('livewire.ui.security.account-security-form'); + } +} diff --git a/app/Livewire/Ui/Security/AuditLogsTable.php b/app/Livewire/Ui/Security/AuditLogsTable.php new file mode 100644 index 0000000..07d5d52 --- /dev/null +++ b/app/Livewire/Ui/Security/AuditLogsTable.php @@ -0,0 +1,13 @@ + ['boolean'], + 'allow_totp' => ['boolean'], + 'allow_email' => ['boolean'], + ]; + } + + public function save(): void + { + $this->validate(); + + // TODO: Werte persistieren (DB/Settings) + // settings()->put('security.2fa', [ + // 'enforce' => $this->enforce, + // 'allow_totp' => $this->allow_totp, + // 'allow_email' => $this->allow_email, + // ]); + + $this->dispatch('toast', body: '2FA-Einstellungen gespeichert.'); + } + + public function render() + { + return view('livewire.ui.security.auth2fa-form'); + } +} diff --git a/app/Livewire/Ui/Security/Fail2BanForm.php b/app/Livewire/Ui/Security/Fail2BanForm.php new file mode 100644 index 0000000..09efbdd --- /dev/null +++ b/app/Livewire/Ui/Security/Fail2BanForm.php @@ -0,0 +1,13 @@ +alreadyActive = (bool) ($u->two_factor_email_enabled ?? false); + } + + public function sendMail(): void + { + if ($this->cooldown > 0) return; + + $u = Auth::user(); + $pin = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + // 10 Minuten gültig (Cache-Key pro User) + Cache::put("email-2fa:setup:{$u->id}", password_hash($pin, PASSWORD_DEFAULT), now()->addMinutes(10)); + + // sehr einfache Notification – ersetze durch Mailables/Markdown: + Notification::route('mail', $u->email)->notify(new \App\Notifications\PlainTextNotification( + subject: 'Dein E-Mail-2FA Code', + lines: [ + "Dein Bestätigungscode lautet: **{$pin}**", + 'Der Code ist 10 Minuten gültig.', + ], + )); + + $this->cooldown = 30; + $this->dispatch('toast', body: 'Code gesendet.'); + $this->dispatch('tick-down'); // optionaler JS-Timer + } + + public function verifyAndEnable(): void + { + $u = Auth::user(); + $hash = Cache::get("email-2fa:setup:{$u->id}"); + if (!$hash || !password_verify(preg_replace('/\D/', '', $this->code), $hash)) { + $this->dispatch('toast', body: 'Code ungültig oder abgelaufen.'); + return; + } + + $u->two_factor_email_enabled = true; // bool Spalte auf users + $u->save(); + Cache::forget("email-2fa:setup:{$u->id}"); + + $this->dispatch('email2fa-enabled'); + $this->dispatch('toast', body: 'E-Mail-2FA aktiviert.'); + $this->dispatch('closeModal'); + } + + public function disable(): void + { + $u = Auth::user(); + $u->two_factor_email_enabled = false; + $u->save(); + + $this->dispatch('email2fa-disabled'); + $this->dispatch('toast', body: 'E-Mail-2FA deaktiviert.'); + $this->dispatch('closeModal'); + } + + public function render() + { + return view('livewire.ui.security.modal.email2fa-setup-modal'); + } +} diff --git a/app/Livewire/Ui/Security/Modal/RecoveryCodesModal.php b/app/Livewire/Ui/Security/Modal/RecoveryCodesModal.php new file mode 100644 index 0000000..69c39fe --- /dev/null +++ b/app/Livewire/Ui/Security/Modal/RecoveryCodesModal.php @@ -0,0 +1,160 @@ +hasExisting = TwoFactorRecoveryCode::where('user_id', $user->id)->exists(); + + // Wichtig: $plainCodes bleiben leer, außer nach generate(). + // So werden bereits existierende Codes nie erneut gezeigt. + $this->plainCodes = []; + $this->justGenerated = false; + } + + /** Erzeugt/rotiert Codes und zeigt sie EINMALIG an */ + public function generate(): void + { + $user = Auth::user(); + + // Alte Codes verwerfen/überschreiben + TwoFactorRecoveryCode::where('user_id', $user->id)->delete(); + + // Neue Codes erzeugen (nur hier im Speicher im Klartext zeigen) + $new = []; + for ($i = 0; $i < $this->count; $i++) { + // 10 zufällige Großbuchstaben/Ziffern + $raw = strtoupper(Str::random($this->length)); + $new[] = $raw; + + TwoFactorRecoveryCode::create([ + 'user_id' => $user->id, + 'code_hash' => hash('sha256', $raw), + 'used_at' => null, + ]); + } + + // Einmalige Anzeige + $this->plainCodes = $new; + $this->hasExisting = true; + $this->justGenerated = true; + } + + /** Optional: E-Mail leicht maskieren für die Dateiüberschrift */ + protected function maskEmail(?string $email): string + { + if (!$email || !str_contains($email, '@')) return '—'; + [$name, $domain] = explode('@', $email, 2); + $nameMasked = Str::substr($name, 0, 1) . str_repeat('•', max(1, strlen($name) - 2)) . Str::substr($name, -1); + $domainMasked = preg_replace('/(^.).*(?=\.)/u', '$1•••', $domain); // z.B. g•••gle.com + return "{$nameMasked}@{$domainMasked}"; + } + + /** Formatiert die TXT-Datei hübsch */ + protected function buildTxtContent(array $codes): string + { + $app = config('app.name', 'App'); + $user = Auth::user(); + $who = $this->maskEmail($user->email ?? ''); + $now = Carbon::now()->toDateTimeString(); + + // Codes als "ABCDE-FGHIJ" + Nummerierung 01), 02), … + $lines = []; + foreach (array_values($codes) as $i => $raw) { + // in 5er Blöcke mit Bindestrich + $pretty = trim(chunk_split(strtoupper($raw), 5, '-'), '-'); + $nr = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT); + $lines[] = "{$nr}) {$pretty}"; + } + + $body = implode(PHP_EOL, $lines); + + $header = <<justGenerated || empty($this->plainCodes)) { + $this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.'); + return null; + } + + $app = Str::slug(config('app.name', 'app')); + $date = now()->format('Y-m-d_His'); + $filename = "{$app}-recovery-codes_{$date}.txt"; + $content = $this->buildTxtContent($this->plainCodes); + + return response()->streamDownload(function () use ($content) { + echo $content . PHP_EOL; // final newline + }, $filename, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + ]); + } + /** Nur direkt nach Generierung möglich */ +// public function downloadTxt() +// { +// if (!$this->justGenerated || empty($this->plainCodes)) { +// // nichts zurückgeben => Livewire bleibt im Modal +// $this->dispatch('toast', body: 'Download nur direkt nach der Erstellung möglich.'); +// return null; +// } +// +// $filename = 'recovery-codes.txt'; +// $content = implode(PHP_EOL, $this->plainCodes); +// +// return response()->streamDownload(function () use ($content) { +// echo $content; +// }, $filename); +// } + + public function render() + { + return view('livewire.ui.security.modal.recovery-codes-modal'); + } +} diff --git a/app/Livewire/Ui/Security/Modal/TotpSetupModal.php b/app/Livewire/Ui/Security/Modal/TotpSetupModal.php new file mode 100644 index 0000000..9a9c9ef --- /dev/null +++ b/app/Livewire/Ui/Security/Modal/TotpSetupModal.php @@ -0,0 +1,89 @@ +> + public static function modalMaxWidth(): string + { + // mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl' + return 'xl'; // kompakt für TOTP + } + + public function mount(): void + { + $user = Auth::user(); + + $ga = new GoogleAuthenticator(); + + // Falls User schon Secret hat: wiederverwenden, sonst neues anlegen + $this->secret = $user->totp_secret ?: $ga->createSecret(); + + $issuer = config('app.name', 'MailWolt'); + // getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI + $this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer); + + $this->alreadyActive = (bool) ($user->two_factor_enabled ?? false); + } + + public function verifyAndEnable(string $code): void + { + $code = preg_replace('/\D/', '', $code ?? ''); + if (strlen($code) !== 6) { + $this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.'); + return; + } + + $ga = new GoogleAuthenticator(); + $ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz + + if (!$ok) { + $this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.'); + return; + } + + $user = Auth::user(); + $user->totp_secret = $this->secret; + $user->two_factor_enabled = true; + $user->save(); + + $this->dispatch('totp-enabled'); + $this->dispatch('toast', body: 'TOTP aktiviert.'); + $this->dispatch('closeModal'); + } + + public function disable(): void + { + $user = Auth::user(); + $user->totp_secret = null; + $user->two_factor_enabled = false; + $user->save(); + + $this->dispatch('totp-disabled'); + $this->dispatch('toast', body: 'TOTP deaktiviert.'); + $this->dispatch('closeModal'); + } + + public function saveAccount() { /* $this->validate(..); user->update([...]) */ } + public function changePassword() { /* validate & set */ } + public function changeEmail() { /* validate, send verify link, etc. */ } + + public function openRecovery() { /* optional modal or page */ } + public function logoutOthers() { /* … */ } + public function logoutSession(string $id) { /* … */ } + + public function render() + { + return view('livewire.ui.security.modal.totp-setup-modal'); + } +} diff --git a/app/Livewire/Ui/Security/RspamdForm.php b/app/Livewire/Ui/Security/RspamdForm.php new file mode 100644 index 0000000..ec824fd --- /dev/null +++ b/app/Livewire/Ui/Security/RspamdForm.php @@ -0,0 +1,13 @@ + 'staging', 'label' => 'Let’s Encrypt (Staging)'], + ['value' => 'production', 'label' => 'Let’s Encrypt (Production)'], + ]; + + public array $acme_challenges = [ + ['value' => 'http01', 'label' => 'HTTP-01 (empfohlen)'], + ['value' => 'dns01', 'label' => 'DNS-01 (Wildcard/komplex)'], + ]; + + /* ========= MTA-STS ========= */ + public bool $mta_sts_enabled = false; + public string $mta_sts_mode = 'enforce'; // testing|enforce|none + public int $mta_sts_max_age = 180; // Tage (Empfehlung: 180) + public array $mta_sts_mx = ['*.example.com']; // neue Liste der mx-Ziele (mind. eins) + +// public bool $mta_sts_include_subdomains = false; +// public string $mta_sts_serve_as = 'static'; // static|route + + /* ========= Zertifikatsliste (Demo) ========= */ + public array $hosts = [ + ['id' => 1, 'host' => 'mail.example.com', 'status' => 'ok', 'expires_at' => '2025-12-01'], + ['id' => 2, 'host' => 'webmail.example.com', 'status' => 'expiring', 'expires_at' => '2025-10-20'], + ['id' => 3, 'host' => 'mx.example.com', 'status' => 'missing', 'expires_at' => null], + ]; + + /* ========= Computed (Previews) ========= */ + + public function getUiHostProperty(): string + { + return "{$this->ui_sub}.{$this->base_domain}"; + } + + public function getWebmailHostProperty(): string + { + return "{$this->webmail_sub}.{$this->base_domain}"; + } + + public function getMtaHostProperty(): string + { + return "{$this->mta_sub}.{$this->base_domain}"; + } + + public function getMtaStsTxtNameProperty(): string + { + return "_mta-sts.{$this->base_domain}"; + } + + public function getMtaStsTxtValueProperty(): string + { + // Beim tatsächlichen Schreiben bitte echten Timestamp/Version setzen. + return 'v=STSv1; id=YYYYMMDD'; + } + + /* ========= Validation ========= */ + + protected function rules(): array + { + return [ + 'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'], + 'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'], + 'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'], + 'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'], + + 'force_https' => ['boolean'], + 'hsts' => ['boolean'], + 'auto_renew' => ['boolean'], + + 'acme_contact' => ['required','email'], + 'acme_env' => ['required', Rule::in(['staging','production'])], + 'acme_challenge' => ['required', Rule::in(['http01','dns01'])], + + 'mta_sts_enabled' => ['boolean'], + 'mta_sts_mode' => ['required', Rule::in(['testing','enforce','none'])], + 'mta_sts_max_age' => ['integer','min:1','max:31536000'], + 'mta_sts_mx' => ['array','min:1'], + 'mta_sts_mx.*' => ['required','string','min:1'], // einfache Prüfung; optional regex auf Hostnamen + +// 'mta_sts_include_subdomains' => ['boolean'], +// 'mta_sts_serve_as' => ['required', Rule::in(['static','route'])], + ]; + } + + /* ========= Lifecycle ========= */ + + public function updated($prop): void + { + // live normalisieren (keine Leerzeichen, keine Protokolle, nur [a-z0-9-]) + if (in_array($prop, ['base_domain','ui_sub','webmail_sub','mta_sub'], true)) { + $this->base_domain = $this->normalizeDomain($this->base_domain); + $this->ui_sub = $this->sanitizeLabel($this->ui_sub); + $this->webmail_sub = $this->sanitizeLabel($this->webmail_sub); + $this->mta_sub = $this->sanitizeLabel($this->mta_sub); + } + } + + + protected function loadMtaStsFromFileIfPossible(): void + { + $file = public_path('.well-known/mta-sts.txt'); + if (!is_file($file)) return; + + $txt = file_get_contents($file); + if (!$txt) return; + + $lines = preg_split('/\r\n|\r|\n/', $txt); + $mx = []; + foreach ($lines as $line) { + [$k,$v] = array_pad(array_map('trim', explode(':', $line, 2)), 2, null); + if (!$k) continue; + if (strcasecmp($k,'version') === 0) { /* ignore */ } + if (strcasecmp($k,'mode') === 0 && $v) $this->mta_sts_mode = strtolower($v); + if (strcasecmp($k,'mx') === 0 && $v) $mx[] = $v; + if (strcasecmp($k,'max_age') === 0 && is_numeric($v)) { + $days = max(1, (int)round(((int)$v)/86400)); + $this->mta_sts_max_age = $days; + } + } + if ($mx) $this->mta_sts_mx = $mx; + $this->mta_sts_enabled = true; + } + + + /* ========= Actions ========= */ + + public function saveDomains(): void + { + $this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']); + // TODO: persist + $this->dispatch('toast', body: 'Domains gespeichert.'); + } + + public function saveTls(): void + { + $this->validate(['force_https','hsts','auto_renew']); + // TODO: persist + $this->dispatch('toast', body: 'TLS/Redirect gespeichert.'); + } + + public function saveAcme(): void + { + $this->validate(['acme_contact','acme_env','acme_challenge','auto_renew']); + // TODO: persist (Kontakt, Env, Challenge, ggf. Auto-Renew Flag) + $this->dispatch('toast', body: 'ACME-Einstellungen gespeichert.'); + } + + public function saveMtaSts(): void + { + $this->validate([ + 'mta_sts_enabled','mta_sts_mode','mta_sts_max_age','mta_sts_mx','mta_sts_mx.*' + ]); + + // TODO: Settings persistieren (z.B. in einer settings-Tabelle) + + // Datei erzeugen/löschen + $wellKnownDir = public_path('.well-known'); + if (!is_dir($wellKnownDir)) { + @mkdir($wellKnownDir, 0755, true); + } + + $file = $wellKnownDir.'/mta-sts.txt'; + + if (!$this->mta_sts_enabled) { + // Policy deaktiviert → Datei entfernen, falls vorhanden + if (is_file($file)) @unlink($file); + $this->dispatch('toast', body: 'MTA-STS deaktiviert und Datei entfernt.'); + return; + } + + $seconds = $this->mta_sts_max_age * 86400; + $mxLines = collect($this->mta_sts_mx) + ->filter(fn($v) => trim($v) !== '') + ->map(fn($v) => "mx: ".trim($v)) + ->implode("\n"); + + // Policy-Text (Plaintext) + $text = "version: STSv1\n". + "mode: {$this->mta_sts_mode}\n". + "{$mxLines}\n". + "max_age: {$seconds}\n"; + + file_put_contents($file, $text); + + $this->dispatch('toast', body: 'MTA-STS gespeichert & Datei aktualisiert.'); + } + + public function addMx(): void + { + $suggest = '*.' . $this->base_domain; + $this->mta_sts_mx[] = $suggest; + } + + public function removeMx(int $index): void + { + if (isset($this->mta_sts_mx[$index])) { + array_splice($this->mta_sts_mx, $index, 1); + } + if (count($this->mta_sts_mx) === 0) { + $this->mta_sts_mx = ['*.' . $this->base_domain]; + } + } + + public function requestCertificate(int $hostId): void + { + // TODO: ACME ausstellen für Host-ID + $this->dispatch('toast', body: 'Zertifikat wird angefordert …'); + } + + public function renewCertificate(int $hostId): void + { + // TODO + $this->dispatch('toast', body: 'Zertifikat wird erneuert …'); + } + + public function revokeCertificate(int $hostId): void + { + // TODO + $this->dispatch('toast', body: 'Zertifikat wird widerrufen …'); + } + + /* ========= Helpers ========= */ + + protected function normalizeDomain(string $d): string + { + $d = strtolower(trim($d)); + $d = preg_replace('/^https?:\/\//', '', $d); + return rtrim($d, '.'); + } + + protected function sanitizeLabel(string $s): string + { + return strtolower(preg_replace('/[^a-z0-9-]/i', '', $s ?? '')); + } + + public function daysLeft(?string $iso): ?int + { + if (!$iso) return null; + try { + $d = (new DateTimeImmutable($iso))->setTime(0,0); + $now = (new DateTimeImmutable('today')); + return (int)$now->diff($d)->format('%r%a'); + } catch (\Throwable) { + return null; + } + } + + public function statusBadge(array $row): array + { + $days = $this->daysLeft($row['expires_at']); + if ($row['status'] === 'missing') { + return ['class' => 'bg-white/8 text-white/50 border-white/10', 'text' => 'kein Zertifikat']; + } + if ($days === null) { + return ['class' => 'bg-white/8 text-white/50 border-white/10', 'text' => '—']; + } + if ($days <= 5) return ['class' => 'bg-rose-500/10 text-rose-300 border-rose-400/30', 'text' => "{$days}d"]; + if ($days <= 20) return ['class' => 'bg-amber-400/10 text-amber-300 border-amber-300/30','text' => "{$days}d"]; + return ['class' => 'bg-emerald-500/10 text-emerald-300 border-emerald-400/30','text' => "{$days}d"]; + } + + public function render() + { + return view('livewire.ui.system.domains-ssl-form'); + } +} diff --git a/app/Livewire/Ui/System/GeneralForm.php b/app/Livewire/Ui/System/GeneralForm.php new file mode 100644 index 0000000..764a3b8 --- /dev/null +++ b/app/Livewire/Ui/System/GeneralForm.php @@ -0,0 +1,50 @@ + 'de', 'label' => 'Deutsch'], + ['value' => 'en', 'label' => 'English'], + ]; + + public array $timezones = [ + 'Europe/Berlin','UTC','Europe/Vienna','Europe/Zurich','America/New_York','Asia/Tokyo' + ]; + + protected function rules(): array + { + return [ + 'locale' => ['required', Rule::in(collect($this->locales)->pluck('value')->all())], + 'timezone' => ['required', Rule::in($this->timezones)], + 'session_timeout' => ['required','integer','min:5','max:1440'], + ]; + } + + public function save(): void + { + $this->validate(); + + // TODO: persist to settings storage (DB/Config) + // e.g. Settings::set('app.locale', $this->locale); + // Settings::set('app.timezone', $this->timezone); + // Settings::set('session.timeout', $this->session_timeout); + + session()->flash('saved', true); + $this->dispatch('toast', body: 'Einstellungen gespeichert.'); + } + + public function render() + { + return view('livewire.ui.system.general-form'); + } +} diff --git a/app/Livewire/Ui/System/SecurityForm.php b/app/Livewire/Ui/System/SecurityForm.php new file mode 100644 index 0000000..f6a427c --- /dev/null +++ b/app/Livewire/Ui/System/SecurityForm.php @@ -0,0 +1,13 @@ + 'required|string|max:10', + 'timezone' => 'required|string|max:64', + 'session_timeout' => 'nullable|integer|min:5|max:1440', + + 'ui_domain' => 'nullable|string|max:190', + 'mail_domain' => 'nullable|string|max:190', + 'webmail_domain' => 'nullable|string|max:190', + 'ssl_auto' => 'boolean', + + 'twofa_enabled' => 'boolean', + 'rate_limit' => 'nullable|integer|min:1|max:100', + 'password_min' => 'nullable|integer|min:6|max:128', + ]; + } + + public function mount(): void + { + // Anzeige-Name der Instanz – lies was du hast (z.B. config('app.name')) + $this->instance_name = (string) (config('app.name') ?? 'MailWolt'); + + // Laden aus unserem einfachen Store + $this->locale = Setting::get('locale', $this->locale); + $this->timezone = Setting::get('timezone', $this->timezone); + $this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout); + + $this->ui_domain = Setting::get('ui_domain', $this->ui_domain); + $this->mail_domain = Setting::get('mail_domain', $this->mail_domain); + $this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain); + $this->ssl_auto = (bool) Setting::get('ssl_auto', $this->ssl_auto); + + $this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled); + $this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit); + $this->password_min = (int) Setting::get('password_min', $this->password_min); + } + + public function save(): void + { + $this->validate(); + + Setting::put('locale', $this->locale); + Setting::put('timezone', $this->timezone); + Setting::put('session_timeout', $this->session_timeout); + + Setting::put('ui_domain', $this->ui_domain); + Setting::put('mail_domain', $this->mail_domain); + Setting::put('webmail_domain', $this->webmail_domain); + Setting::put('ssl_auto', $this->ssl_auto); + + Setting::put('twofa_enabled', $this->twofa_enabled); + Setting::put('rate_limit', $this->rate_limit); + Setting::put('password_min', $this->password_min); + + $this->dispatch('toast', type: 'success', message: 'Einstellungen gespeichert'); // optional + } + + public function render() + { + return view('livewire.ui.system.settings-form'); + } +} diff --git a/app/Models/DkimKey.php b/app/Models/DkimKey.php index ed716ac..77596d4 100644 --- a/app/Models/DkimKey.php +++ b/app/Models/DkimKey.php @@ -15,4 +15,9 @@ class DkimKey extends Model public function domain(): BelongsTo { return $this->belongsTo(Domain::class); } + + public function asTxtValue(): string { + return 'v=DKIM1; k=rsa; p=' . $this->public_key_txt; + } + } diff --git a/app/Models/DmarcRecord.php b/app/Models/DmarcRecord.php new file mode 100644 index 0000000..8e855f7 --- /dev/null +++ b/app/Models/DmarcRecord.php @@ -0,0 +1,22 @@ + 'int','is_active' => 'boolean']; + + public function domain(): BelongsTo { return $this->belongsTo(Domain::class); } + + public function renderTxt(): string { + $parts = ['v=DMARC1', "p={$this->policy}", "pct={$this->pct}"]; + if ($this->rua) $parts[] = "rua={$this->rua}"; + if ($this->ruf) $parts[] = "ruf={$this->ruf}"; + return implode('; ', $parts); + } + +} diff --git a/app/Models/Domain.php b/app/Models/Domain.php index bb34ccb..6fbc419 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -7,18 +7,32 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Domain extends Model { - protected $fillable = ['domain','is_active']; + protected $fillable = ['domain','is_active','is_system']; protected $casts = ['is_active' => 'bool']; - public function mailboxes(): HasMany { - return $this->hasMany(MailUser::class); - } - - public function aliases(): HasMany { + public function aliases(): HasMany + { return $this->hasMany(MailAlias::class); } - public function dkimKeys(): HasMany { + public function users(): HasMany + { + return $this->hasMany(MailUser::class); + } + + public function dkimKeys(): HasMany + { return $this->hasMany(DkimKey::class); } + + public function spf(): HasMany + { + return $this->hasMany(SpfRecord::class); + } + + public function dmarc(): HasMany + { + return $this->hasMany(DmarcRecord::class); + } + } diff --git a/app/Models/MailUser.php b/app/Models/MailUser.php index bd0bcec..c3b2e7e 100644 --- a/app/Models/MailUser.php +++ b/app/Models/MailUser.php @@ -7,16 +7,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MailUser extends Model { - protected $table = 'mail_users'; +// protected $table = 'mail_users'; protected $fillable = [ 'domain_id','localpart','email','password_hash', - 'is_active','must_change_pw','quota_mb','last_login_at', + 'is_active','must_change_pw','quota_mb','is_system' ]; protected $hidden = ['password_hash']; protected $casts = [ 'is_active'=>'bool', + 'is_system' => 'boolean', 'must_change_pw'=>'bool', 'quota_mb'=>'int', 'last_login_at'=>'datetime', @@ -26,7 +27,6 @@ class MailUser extends Model return $this->belongsTo(Domain::class); } - // Komfort: Passwort setzen → bcrypt (BLF-CRYPT) public function setPasswordAttribute(string $plain): void { // optional: allow 'password' virtual attribute $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT); @@ -34,6 +34,9 @@ class MailUser extends Model // Scopes public function scopeActive($q) { return $q->where('is_active', true); } + + public function scopeSystem($q) { return $q->where('is_system', true); } + public function scopeByEmail($q, string $email) { return $q->where('email', $email); } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 60b5309..b2f583e 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class Setting extends Model { @@ -22,4 +23,11 @@ class Setting extends Model $val = is_array($value) ? json_encode($value, JSON_UNESCAPED_SLASHES) : (string)$value; static::query()->updateOrCreate(['key'=>$key], ['value'=>$val]); } + + public static function signupAllowed() + { + $value = self::where('key', 'signup_enabled')->value('value'); + return is_null($value) || (int) $value === 1; + } + } diff --git a/app/Models/SpfRecord.php b/app/Models/SpfRecord.php new file mode 100644 index 0000000..6300906 --- /dev/null +++ b/app/Models/SpfRecord.php @@ -0,0 +1,15 @@ + 'boolean']; + + public function domain(): BelongsTo { return $this->belongsTo(Domain::class); } + +} diff --git a/app/Models/TlsaRecord.php b/app/Models/TlsaRecord.php new file mode 100644 index 0000000..ea16ca8 --- /dev/null +++ b/app/Models/TlsaRecord.php @@ -0,0 +1,19 @@ +belongsTo(Domain::class); } + + public function getDnsStringAttribute(): string + { + return "{$this->service}.{$this->host}. IN TLSA {$this->usage} {$this->selector} {$this->matching} {$this->hash}"; + } +} diff --git a/app/Models/TwoFactorMethod.php b/app/Models/TwoFactorMethod.php new file mode 100644 index 0000000..7492983 --- /dev/null +++ b/app/Models/TwoFactorMethod.php @@ -0,0 +1,18 @@ + 'array', + 'enabled' => 'bool', + 'confirmed_at' => 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/TwoFactorRecoveryCode.php b/app/Models/TwoFactorRecoveryCode.php new file mode 100644 index 0000000..2c6aeb0 --- /dev/null +++ b/app/Models/TwoFactorRecoveryCode.php @@ -0,0 +1,82 @@ + 'datetime', + ]; + + // === Beziehungen === + public function user() + { + return $this->belongsTo(User::class); + } + + // === Logik === + + /** + * Prüft, ob der eingegebene Code gültig ist (noch nicht benutzt & hash-match) + */ + public static function verifyAndConsume(int $userId, string $inputCode): bool + { + $codes = self::where('user_id', $userId) + ->whereNull('used_at') + ->get(); + + foreach ($codes as $code) { + if (password_verify($inputCode, $code->code_hash)) { + $code->update(['used_at' => now()]); + return true; + } + } + + return false; + } + + /** + * Prüft, ob der Code für den Benutzer existiert (ohne ihn zu verbrauchen) + */ + public static function checkValid(int $userId, string $inputCode): bool + { + return self::where('user_id', $userId) + ->whereNull('used_at') + ->get() + ->contains(fn($c) => password_verify($inputCode, $c->code_hash)); + } + + /** + * Generiert neue Recovery-Codes (löscht alte unbenutzte) + */ + public static function generateNewSet(int $userId, int $count = 10): array + { + self::where('user_id', $userId)->delete(); + + $plainCodes = []; + for ($i = 0; $i < $count; $i++) { + $plain = strtoupper(str()->random(10)); + self::create([ + 'user_id' => $userId, + 'code_hash' => password_hash($plain, PASSWORD_DEFAULT), + ]); + $plainCodes[] = $plain; + } + + return $plainCodes; + } + +} diff --git a/app/Models/User.php b/app/Models/User.php index 314b5e3..3e2eee2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\Role; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -31,11 +32,28 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'is_active' => 'boolean', 'must_change_pw' => 'boolean', + 'role' => Role::class, ]; /** Quick helper: check if user is admin */ public function isAdmin(): bool { - return $this->role === 'admin'; + return $this->role === Role::Admin; } + + public function twoFactorMethods() + { + return $this->hasMany(\App\Models\TwoFactorMethod::class); + } + + public function twoFactorEnabled(): bool + { + return $this->twoFactorMethods()->where('enabled', true)->exists(); + } + + public function twoFactorMethod(string $method): ?\App\Models\TwoFactorMethod + { + return $this->twoFactorMethods()->where('method', $method)->first(); + } + } diff --git a/app/Services/DnsRecordService.php b/app/Services/DnsRecordService.php new file mode 100644 index 0000000..f1d3c79 --- /dev/null +++ b/app/Services/DnsRecordService.php @@ -0,0 +1,69 @@ + 'A', + 'name' => $domain->domain, + 'value' => 'DEINE.SERVER.IP', // optional: aus Installer einsetzen + 'ttl' => 3600, + ]; + + // MX + $records[] = [ + 'type' => 'MX', + 'name' => $domain->domain, + 'value' => "10 $mtaHost.", + 'ttl' => 3600, + ]; + + // SPF + $records[] = [ + 'type' => 'TXT', + 'name' => $domain->domain, + 'value' => 'v=spf1 mx a -all', + 'ttl' => 3600, + ]; + + // DKIM (nimm den neuesten aktiven Key, falls vorhanden) + /** @var DkimKey|null $dkim */ + $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); + if ($dkim) { + $records[] = [ + 'type' => 'TXT', + 'name' => "{$dkim->selector}._domainkey.{$domain->domain}", + 'value' => "v=DKIM1; k=rsa; p={$dkim->public_key_txt}", + 'ttl' => 3600, + ]; + } + + // DMARC (Default p=none – in UI änderbar) + $records[] = [ + 'type' => 'TXT', + 'name' => "_dmarc.{$domain->domain}", + 'value' => "v=DMARC1; p=none; rua=mailto:dmarc@{$domain->domain}; pct=100", + 'ttl' => 3600, + ]; + + // Optional: Webmail/UI CNAMEs + $records[] = ['type'=>'CNAME','name'=>"webmail.{$domain->domain}",'value'=>"$webmail.",'ttl'=>3600]; + $records[] = ['type'=>'CNAME','name'=>"ui.{$domain->domain}", 'value'=>"$uiHost.", 'ttl'=>3600]; + + return $records; + } +} diff --git a/app/Support/Hostnames.php b/app/Support/Hostnames.php new file mode 100644 index 0000000..978dbbb --- /dev/null +++ b/app/Support/Hostnames.php @@ -0,0 +1,19 @@ + $taskId, + 'title' => null, + 'message' => null, + 'badge' => null, + 'state' => 'queued', // queued|running|done|failed + 'position' => 'bottom-right', + 'updated_at' => time(), + ], $data); + + // Task-JSON + Redis::setex("task:$taskId", self::TTL, json_encode($payload)); + + // User-Set + Redis::sadd("user:$userId:tasks", $taskId); + Redis::expire("user:$userId:tasks", self::TTL); + + // WS raus + event(new TaskUpdated($taskId, $userId, $payload)); + } + + public static function done(int $userId, string $taskId, string $message = 'Fertig.'): void + { + self::update($userId, $taskId, ['state' => 'done', 'message' => $message]); + } + + public static function fail(int $userId, string $taskId, string $message = 'Fehlgeschlagen.'): void + { + self::update($userId, $taskId, ['state' => 'failed', 'message' => $message]); + } + + public static function update(int $userId, string $taskId, array $patch): void + { + $key = "task:$taskId"; + $raw = Redis::get($key); + $base = $raw ? json_decode($raw, true) : ['id' => $taskId]; + $payload = array_merge($base, $patch, ['updated_at' => time()]); + Redis::setex($key, self::TTL, json_encode($payload)); + Redis::sadd("user:$userId:tasks", $taskId); + Redis::expire("user:$userId:tasks", self::TTL); + + event(new TaskUpdated($taskId, $userId, $payload)); + } + + /** Initiale Tasks eines Users lesen */ + public static function listForUser(int $userId): array + { + $ids = Redis::smembers("user:$userId:tasks") ?: []; + $items = []; + foreach ($ids as $id) { + $raw = Redis::get("task:$id"); + if ($raw) $items[] = json_decode($raw, true); + } + // optional: nach updated_at sortiert + usort($items, fn($a,$b) => ($b['updated_at']??0) <=> ($a['updated_at']??0)); + return $items; + } + + /** Optional: Client bestätigt (entfernen, aber Task-Key leben lassen bis TTL) */ + public static function ack(int $userId, string $taskId): void + { + Redis::srem("user:$userId:tasks", $taskId); + } +} diff --git a/app/View/Components/Partials/Header.php b/app/View/Components/Partials/Header.php new file mode 100644 index 0000000..8491c01 --- /dev/null +++ b/app/View/Components/Partials/Header.php @@ -0,0 +1,27 @@ +withRouting( web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', commands: __DIR__ . '/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'ensure.setup' => \App\Http\Middleware\EnsureSetupCompleted::class, + 'signup.open' => \App\Http\Middleware\SignupOpen::class, ]); }) diff --git a/composer.json b/composer.json index 06aff35..715d84a 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,10 @@ "laravel/framework": "^12.0", "laravel/reverb": "^1.6", "laravel/tinker": "^2.10.1", - "livewire/livewire": "^3.6" + "livewire/livewire": "^3.6", + "vectorface/googleauthenticator": "^3.4", + "wire-elements/modal": "^2.0", + "ext-openssl": "*" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index f3d0252..61a1ffe 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,62 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cf63d9c3e0078c5b2ed8141614d6429a", + "content-hash": "b5113b6186747bf4e181997c30721794", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || 11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" + }, + "time": "2024-10-01T13:55:55+00:00" + }, { "name": "brick/math", "version": "0.14.0", @@ -265,6 +319,56 @@ ], "time": "2025-01-03T16:18:33+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -639,6 +743,78 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "endroid/qr-code", + "version": "6.0.9", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.2" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.0.9" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2025-07-13T19:59:45+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -1232,16 +1408,16 @@ }, { "name": "laravel/framework", - "version": "v12.31.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "281b711710c245dd8275d73132e92635be3094df" + "reference": "77b2740391cd2a825ba59d6fada45e9b8b9bcc5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/281b711710c245dd8275d73132e92635be3094df", - "reference": "281b711710c245dd8275d73132e92635be3094df", + "url": "https://api.github.com/repos/laravel/framework/zipball/77b2740391cd2a825ba59d6fada45e9b8b9bcc5a", + "reference": "77b2740391cd2a825ba59d6fada45e9b8b9bcc5a", "shasum": "" }, "require": { @@ -1269,7 +1445,6 @@ "monolog/monolog": "^3.0", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", - "phiki/phiki": "^2.0.0", "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", @@ -1448,7 +1623,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-23T15:33:04+00:00" + "time": "2025-09-30T17:39:22+00:00" }, { "name": "laravel/prompts", @@ -2851,16 +3026,16 @@ }, { "name": "paragonie/sodium_compat", - "version": "v2.2.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "9c3535883f1b60b5d26aeae5914bbec61132ad7f" + "reference": "547e2dc4d45107440e76c17ab5a46e4252460158" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/9c3535883f1b60b5d26aeae5914bbec61132ad7f", - "reference": "9c3535883f1b60b5d26aeae5914bbec61132ad7f", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/547e2dc4d45107440e76c17ab5a46e4252460158", + "reference": "547e2dc4d45107440e76c17ab5a46e4252460158", "shasum": "" }, "require": { @@ -2941,80 +3116,9 @@ ], "support": { "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v2.2.0" + "source": "https://github.com/paragonie/sodium_compat/tree/v2.4.0" }, - "time": "2025-09-21T18:27:14+00:00" - }, - { - "name": "phiki/phiki", - "version": "v2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phikiphp/phiki.git", - "reference": "160785c50c01077780ab217e5808f00ab8f05a13" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phikiphp/phiki/zipball/160785c50c01077780ab217e5808f00ab8f05a13", - "reference": "160785c50c01077780ab217e5808f00ab8f05a13", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "league/commonmark": "^2.5.3", - "php": "^8.2", - "psr/simple-cache": "^3.0" - }, - "require-dev": { - "illuminate/support": "^11.45", - "laravel/pint": "^1.18.1", - "orchestra/testbench": "^9.15", - "pestphp/pest": "^3.5.1", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^7.1.6" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Phiki\\Adapters\\Laravel\\PhikiServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Phiki\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ryan Chandler", - "email": "support@ryangjchandler.co.uk", - "homepage": "https://ryangjchandler.co.uk", - "role": "Developer" - } - ], - "description": "Syntax highlighting using TextMate grammars in PHP.", - "support": { - "issues": "https://github.com/phikiphp/phiki/issues", - "source": "https://github.com/phikiphp/phiki/tree/v2.0.4" - }, - "funding": [ - { - "url": "https://github.com/sponsors/ryangjchandler", - "type": "github" - }, - { - "url": "https://buymeacoffee.com/ryangjchandler", - "type": "other" - } - ], - "time": "2025-09-20T17:21:02+00:00" + "time": "2025-10-06T08:47:40+00:00" }, { "name": "phpoption/phpoption", @@ -4429,6 +4533,67 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.92.7", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.5.24|^10.5|^11.5", + "spatie/pest-plugin-test-time": "^1.1|^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-07-17T15:46:43+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", @@ -4505,16 +4670,16 @@ }, { "name": "symfony/console", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" + "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", "shasum": "" }, "require": { @@ -4579,7 +4744,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.3" + "source": "https://github.com/symfony/console/tree/v7.3.4" }, "funding": [ { @@ -4599,7 +4764,7 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2025-09-22T15:31:00+00:00" }, { "name": "symfony/css-selector", @@ -4735,16 +4900,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", "shasum": "" }, "require": { @@ -4792,7 +4957,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + "source": "https://github.com/symfony/error-handler/tree/v7.3.4" }, "funding": [ { @@ -4812,7 +4977,7 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:57+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/event-dispatcher", @@ -5044,16 +5209,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00" + "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00", - "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6", + "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6", "shasum": "" }, "require": { @@ -5103,7 +5268,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.4" }, "funding": [ { @@ -5123,20 +5288,20 @@ "type": "tidelift" } ], - "time": "2025-08-20T08:04:18+00:00" + "time": "2025-09-16T08:38:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b" + "reference": "b796dffea7821f035047235e076b60ca2446e3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b", - "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf", + "reference": "b796dffea7821f035047235e076b60ca2446e3cf", "shasum": "" }, "require": { @@ -5221,7 +5386,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.4" }, "funding": [ { @@ -5241,20 +5406,20 @@ "type": "tidelift" } ], - "time": "2025-08-29T08:23:45+00:00" + "time": "2025-09-27T12:32:17+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575" + "reference": "ab97ef2f7acf0216955f5845484235113047a31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575", - "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", + "reference": "ab97ef2f7acf0216955f5845484235113047a31d", "shasum": "" }, "require": { @@ -5305,7 +5470,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.3" + "source": "https://github.com/symfony/mailer/tree/v7.3.4" }, "funding": [ { @@ -5325,20 +5490,20 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-09-17T05:51:54+00:00" }, { "name": "symfony/mime", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35", + "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35", "shasum": "" }, "require": { @@ -5393,7 +5558,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.2" + "source": "https://github.com/symfony/mime/tree/v7.3.4" }, "funding": [ { @@ -5413,7 +5578,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-09-16T08:38:17+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6246,16 +6411,16 @@ }, { "name": "symfony/process", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "32241012d521e2e8a9d713adb0812bb773b907f1" + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1", - "reference": "32241012d521e2e8a9d713adb0812bb773b907f1", + "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", "shasum": "" }, "require": { @@ -6287,7 +6452,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.3" + "source": "https://github.com/symfony/process/tree/v7.3.4" }, "funding": [ { @@ -6307,20 +6472,20 @@ "type": "tidelift" } ], - "time": "2025-08-18T09:42:54+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/routing", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c", + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c", "shasum": "" }, "require": { @@ -6372,7 +6537,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.2" + "source": "https://github.com/symfony/routing/tree/v7.3.4" }, "funding": [ { @@ -6392,7 +6557,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/service-contracts", @@ -6479,16 +6644,16 @@ }, { "name": "symfony/string", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" + "reference": "f96476035142921000338bad71e5247fbc138872" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", "shasum": "" }, "require": { @@ -6503,7 +6668,6 @@ }, "require-dev": { "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -6546,7 +6710,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.3" + "source": "https://github.com/symfony/string/tree/v7.3.4" }, "funding": [ { @@ -6566,20 +6730,20 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2025-09-11T14:36:48+00:00" }, { "name": "symfony/translation", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "e0837b4cbcef63c754d89a4806575cada743a38d" + "reference": "ec25870502d0c7072d086e8ffba1420c85965174" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d", - "reference": "e0837b4cbcef63c754d89a4806575cada743a38d", + "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174", "shasum": "" }, "require": { @@ -6646,7 +6810,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.3" + "source": "https://github.com/symfony/translation/tree/v7.3.4" }, "funding": [ { @@ -6666,7 +6830,7 @@ "type": "tidelift" } ], - "time": "2025-08-01T21:02:37+00:00" + "time": "2025-09-07T11:39:36+00:00" }, { "name": "symfony/translation-contracts", @@ -6822,16 +6986,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f" + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", - "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", "shasum": "" }, "require": { @@ -6885,7 +7049,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" }, "funding": [ { @@ -6905,7 +7069,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6962,6 +7126,63 @@ }, "time": "2024-12-21T16:25:41+00:00" }, + { + "name": "vectorface/googleauthenticator", + "version": "v3.4", + "source": { + "type": "git", + "url": "https://github.com/Vectorface/GoogleAuthenticator.git", + "reference": "48f5571e1496a6421e7975d9884860cb3cc918d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Vectorface/GoogleAuthenticator/zipball/48f5571e1496a6421e7975d9884860cb3cc918d8", + "reference": "48f5571e1496a6421e7975d9884860cb3cc918d8", + "shasum": "" + }, + "require": { + "endroid/qr-code": "^6.0.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Vectorface\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Michael Kliewe", + "email": "info@phpgangsta.de", + "homepage": "http://www.phpgangsta.de/", + "role": "Developer" + }, + { + "name": "Francis Lavoie", + "email": "francis@vectorface.com", + "homepage": "http://vectorface.com/", + "role": "Developer" + } + ], + "description": "Google Authenticator 2-factor authentication", + "keywords": [ + "googleauthenticator", + "rfc6238", + "totp" + ], + "support": { + "issues": "https://github.com/Vectorface/GoogleAuthenticator/issues", + "source": "https://github.com/Vectorface/GoogleAuthenticator" + }, + "time": "2025-07-23T17:32:36+00:00" + }, { "name": "vlucas/phpdotenv", "version": "v5.6.2", @@ -7177,6 +7398,70 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "wire-elements/modal", + "version": "2.0.13", + "source": { + "type": "git", + "url": "https://github.com/wire-elements/modal.git", + "reference": "65d9db80a0befaa38ae99a47a26818e784aa7101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wire-elements/modal/zipball/65d9db80a0befaa38ae99a47a26818e784aa7101", + "reference": "65d9db80a0befaa38ae99a47a26818e784aa7101", + "shasum": "" + }, + "require": { + "livewire/livewire": "^3.2.3", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "require-dev": { + "orchestra/testbench": "^8.5", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LivewireUI\\Modal\\LivewireModalServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LivewireUI\\Modal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Philo Hermans", + "email": "me@philohermans.com" + } + ], + "description": "Laravel Livewire modal component", + "keywords": [ + "laravel", + "livewire", + "modal" + ], + "support": { + "issues": "https://github.com/wire-elements/modal/issues", + "source": "https://github.com/wire-elements/modal/tree/2.0.13" + }, + "funding": [ + { + "url": "https://github.com/PhiloNL", + "type": "github" + } + ], + "time": "2025-02-20T13:07:12+00:00" } ], "packages-dev": [ @@ -8270,16 +8555,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.41", + "version": "11.5.42", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b42782bcb947d2c197aea42ce9714ee2d974b283" + "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b42782bcb947d2c197aea42ce9714ee2d974b283", - "reference": "b42782bcb947d2c197aea42ce9714ee2d974b283", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c", + "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c", "shasum": "" }, "require": { @@ -8351,7 +8636,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.41" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.42" }, "funding": [ { @@ -8375,7 +8660,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:32:10+00:00" + "time": "2025-09-28T12:09:13+00:00" }, { "name": "sebastian/cli-parser", @@ -9548,7 +9833,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.2", + "ext-openssl": "*" }, "platform-dev": [], "plugin-api-version": "2.3.0" diff --git a/config/app.php b/config/app.php index 423eed5..d081dcb 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,6 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + 'version' => env('APP_VERSION', '1.0.0') + ]; diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..4b42de4 100644 --- a/config/auth.php +++ b/config/auth.php @@ -111,5 +111,4 @@ return [ */ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - ]; diff --git a/config/broadcasting.php b/config/broadcasting.php index 907c6fe..77f08b0 100644 --- a/config/broadcasting.php +++ b/config/broadcasting.php @@ -15,7 +15,7 @@ return [ | */ - 'default' => env('BROADCAST_CONNECTION', 'null'), + 'default' => env('BROADCAST_DRIVER', 'null'), /* |-------------------------------------------------------------------------- @@ -44,19 +44,50 @@ return [ // 'client_options' => [ // // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html // ], +// ], + +// 'reverb' => [ +// 'driver' => 'reverb', +// 'key' => env('REVERB_APP_KEY'), +// 'secret' => env('REVERB_APP_SECRET'), +// 'app_id' => env('REVERB_APP_ID'), +// 'options' => [ +// 'host' => env('REVERB_HOST', '127.0.0.1'), +// 'port' => (int) env('REVERB_PORT', 443), // <- egal, wird für HTTP-Client überschrieben +// 'scheme' => env('REVERB_SCHEME', 'https'), // <- dito +// 'path' => env('REVERB_PATH', '/ws'), // <- WICHTIG: hier /ws! +// 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', +// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL), +// +//// 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'), +//// 'port' => (int) env('REVERB_SERVER_PORT', 8080), +//// 'scheme' => env('REVERB_SERVER_SCHEME', 'http'), +//// 'path' => env('REVERB_SERVER_PATH', '/ws'), +//// 'useTLS' => env('REVERB_SERVER_SCHEME', 'http') === 'https', +//// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL), +// +//// 'host' => env('REVERB_HOST'), +//// 'port' => (int) env('REVERB_PORT', 443), +//// 'scheme' => env('REVERB_SCHEME', 'http'), +//// 'path' => env('REVERB_PATH', '/ws'), +//// 'useTLS' => env('REVERB_SCHEME', 'http') === 'https', +//// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL), +// ], // ], 'reverb' => [ 'driver' => 'reverb', - 'key' => env('REVERB_APP_KEY'), + 'key' => env('REVERB_APP_KEY'), 'secret' => env('REVERB_APP_SECRET'), 'app_id' => env('REVERB_APP_ID'), 'options' => [ - 'host' => env('REVERB_HOST'), - 'port' => (int) env('REVERB_PORT', 443), - 'scheme' => env('REVERB_SCHEME', 'https'), - 'path' => env('REVERB_PATH', '/ws'), - 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + // *** Wichtig: interner HTTP-Endpunkt des Reverb-Servers *** + 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'), + 'port' => (int) env('REVERB_SERVER_PORT', 8080), + 'scheme' => 'http', + 'path' => '', // *** KEIN /ws hier! *** + 'useTLS' => false, + 'verify' => false, // kein TLS → egal, zur Sicherheit false ], ], diff --git a/config/menu/sidebar.php b/config/menu/sidebar.php new file mode 100644 index 0000000..514e7c7 --- /dev/null +++ b/config/menu/sidebar.php @@ -0,0 +1,63 @@ + 'Übersicht', 'icon' => 'ph-gauge', 'items' => [ + ['label' => 'Dashboard', 'route' => 'ui.dashboard', 'icon' => 'ph-house'], + ], + ], + [ + 'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [ + ['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'], + ['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'], + ['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'], + ['label' => 'Filter', 'route' => 'ui.mail.filters.index'], + ['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'], + ['label' => 'Queues', 'route' => 'ui.mail.queues.index'], + ], + ], + [ + 'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [ + ['label' => 'Übersicht', 'route' => 'ui.domains.index'], + ['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'], + ['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'], + ], + ], + [ + 'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [ + ['label' => 'Allgemein', 'route' => 'ui.webmail.settings'], + ['label' => 'Plugins', 'route' => 'ui.webmail.plugins'], + ], + ], + [ + 'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [ + ['label' => 'Benutzer', 'route' => 'ui.users.index'], + ['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'], + ['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'], + ], + ], + [ + 'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [ + ['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'], + ['label' => 'Ratelimits', 'route' => 'ui.security.abuse'], + ['label' => 'Audit-Logs', 'route' => 'ui.security.audit'], + ], + ], + [ + 'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [ + ['label' => 'Einstellungen', 'route' => 'ui.system.settings'], + ['label' => 'Dienste & Status', 'route' => 'ui.system.services'], + ['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'], + ['label' => 'Logs', 'route' => 'ui.system.logs'], + ['label' => 'Speicher', 'route' => 'ui.system.storage'], + ['label' => 'Über', 'route' => 'ui.system.about'], + ], + ], + [ + 'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [ + ['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'], + ['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'], + ['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'], + ], + ], +]; diff --git a/config/reverb.php b/config/reverb.php index 20687ef..9059d3d 100644 --- a/config/reverb.php +++ b/config/reverb.php @@ -26,16 +26,49 @@ return [ | */ +// 'servers' => [ +// 'reverb' => [ +// 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'), +// 'port' => env('REVERB_SERVER_PORT', 8080), +// 'path' => env('REVERB_SERVER_PATH', '/ws'), +// 'scheme' => env('REVERB_SERVER_SCHEME', 'http'), +// 'key' => env('REVERB_APP_KEY'), +// 'hostname' => env('REVERB_HOST'), +// 'options' => [ +// 'tls' => ['verify' => false], +//// 'tls' => [], +// ], +// 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), +// 'scaling' => [ +// 'enabled' => env('REVERB_SCALING_ENABLED', false), +// 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), +// 'server' => [ +// 'url' => env('REDIS_URL'), +// 'host' => env('REDIS_HOST', '127.0.0.1'), +// 'port' => env('REDIS_PORT', '6379'), +// 'username' => env('REDIS_USERNAME'), +// 'password' => env('REDIS_PASSWORD'), +// 'database' => env('REDIS_DB', '0'), +// 'timeout' => env('REDIS_TIMEOUT', 60), +// ], +// ], +// 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), +// 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), +// ], +// +// ], + 'servers' => [ 'reverb' => [ - 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'), - 'port' => env('REVERB_SERVER_PORT', 8080), - 'path' => env('REVERB_SERVER_PATH', '/ws'), - 'scheme' => env('REVERB_SERVER_SCHEME', 'https'), - 'key' => env('REVERB_APP_KEY'), - 'hostname' => env('REVERB_HOST'), + // *** Wo der artisan-Server bindet *** + 'host' => env('REVERB_SERVER_HOST', '127.0.0.1'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), // WS-Pfad + 'scheme' => env('REVERB_SERVER_SCHEME', 'http'), + 'key' => env('REVERB_APP_KEY'), + 'hostname' => env('REVERB_HOST'), // optional für Logs 'options' => [ - 'tls' => [], + 'tls' => ['verify' => false], // wir nutzen http → irrelevant ], 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), 'scaling' => [ @@ -54,7 +87,6 @@ return [ 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), ], - ], /* @@ -68,29 +100,56 @@ return [ | */ +// 'apps' => [ +// +// 'provider' => 'config', +// +// 'apps' => [ +// [ +// 'key' => env('REVERB_APP_KEY'), +// 'secret' => env('REVERB_APP_SECRET'), +// 'app_id' => env('REVERB_APP_ID'), +// 'options' => [ +// 'host' => env('REVERB_HOST', '127.0.0.1'), +// 'port' => (int) env('REVERB_PORT', 443), +// 'scheme' => env('REVERB_SCHEME', 'https'), +// 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', +// +//// 'host' => env('REVERB_HOST'), +//// 'port' => env('REVERB_PORT', 443), +//// 'scheme' => env('REVERB_SCHEME', 'https'), +//// 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', +//// 'verify' => filter_var(env('REVERB_VERIFY_TLS', true), FILTER_VALIDATE_BOOL), +// ], +// 'allowed_origins' => ['*'], +// 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), +// 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), +// 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), +// 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), +// ], +// ], +// +// ], + 'apps' => [ - 'provider' => 'config', - - 'apps' => [ - [ - 'key' => env('REVERB_APP_KEY'), - 'secret' => env('REVERB_APP_SECRET'), - 'app_id' => env('REVERB_APP_ID'), - 'options' => [ - 'host' => env('REVERB_HOST'), - 'port' => env('REVERB_PORT', 443), - 'scheme' => env('REVERB_SCHEME', 'https'), - 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', - ], - 'allowed_origins' => ['*'], - 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), - 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), - 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), - 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'apps' => [[ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + // *** Öffentlicher WS-Endpunkt (über Nginx) – nur Info/Signatur *** + 'options' => [ + 'host' => env('REVERB_HOST', '127.0.0.1'), + 'port' => (int) env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', ], - ], - + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + ]], ], ]; diff --git a/config/ui-menu.php b/config/ui-menu.php new file mode 100644 index 0000000..2f9a5fc --- /dev/null +++ b/config/ui-menu.php @@ -0,0 +1,63 @@ + 'Übersicht', 'icon' => 'ph-gauge', 'items' => [ + ['label' => 'Dashboard', 'route' => 'ui.dashboard', 'icon' => 'ph-house'], + ], + ], + [ + 'label' => 'Mail', 'icon' => 'ph-envelope', 'items' => [ + ['label' => 'Postfächer', 'route' => 'ui.mail.mailboxes.index'], + ['label' => 'Aliasse', 'route' => 'ui.mail.aliases.index'], + ['label' => 'Gruppen', 'route' => 'ui.mail.groups.index'], + ['label' => 'Filter', 'route' => 'ui.mail.filters.index'], + ['label' => 'Quarantäne', 'route' => 'ui.mail.quarantine.index'], + ['label' => 'Queues', 'route' => 'ui.mail.queues.index'], + ], + ], + [ + 'label' => 'Domains', 'icon' => 'ph-globe', 'items' => [ + ['label' => 'Übersicht', 'route' => 'ui.domains.index'], + ['label' => 'DNS-Assistent', 'route' => 'ui.domains.dns'], + ['label' => 'Zertifikate', 'route' => 'ui.domains.certificates'], + ], + ], + [ + 'label' => 'Webmail', 'icon' => 'ph-browser', 'items' => [ + ['label' => 'Allgemein', 'route' => 'ui.webmail.settings'], + ['label' => 'Plugins', 'route' => 'ui.webmail.plugins'], + ], + ], + [ + 'label' => 'Benutzer', 'icon' => 'ph-users', 'items' => [ + ['label' => 'Benutzer', 'route' => 'ui.users.index'], + ['label' => 'Rollen & Rechte', 'route' => 'ui.users.roles'], + ['label' => 'Anmeldesicherheit', 'route' => 'ui.users.security'], + ], + ], + [ + 'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [ + ['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'], + ['label' => 'Ratelimits', 'route' => 'ui.security.abuse'], + ['label' => 'Audit-Logs', 'route' => 'ui.security.audit'], + ], + ], + [ + 'label' => 'System', 'icon' => 'ph-gear-six', 'items' => [ + ['label' => 'Einstellungen', 'route' => 'ui.settings.index'], + ['label' => 'Dienste & Status', 'route' => 'ui.system.services'], + ['label' => 'Jobs & Queues', 'route' => 'ui.system.jobs'], + ['label' => 'Logs', 'route' => 'ui.system.logs'], + ['label' => 'Speicher', 'route' => 'ui.system.storage'], + ['label' => 'Über', 'route' => 'ui.system.about'], + ], + ], + [ + 'label' => 'Entwickler', 'icon' => 'ph-brackets-curly', 'items' => [ + ['label' => 'API-Schlüssel', 'route' => 'ui.dev.tokens'], + ['label' => 'Webhooks', 'route' => 'ui.dev.webhooks'], + ['label' => 'Sandbox', 'route' => 'ui.dev.sandbox'], + ], + ], +]; diff --git a/config/ui/header.php b/config/ui/header.php new file mode 100644 index 0000000..a0a3152 --- /dev/null +++ b/config/ui/header.php @@ -0,0 +1,44 @@ + 'Notification', + 'icon' => 'icons.icon-notification', + 'image' => false, + 'route' => 'dashboard', + 'roles' => ['super_admin', 'admin', 'employee', 'user'], + ], + [ + 'title' => 'Avatar', + 'icon' => 'icons.icon-team', + 'image' => true, + 'route' => 'dashboard', + 'roles' => ['super_admin', 'admin', 'employee', 'user'], + 'sub' => [ + [ + 'title' => 'Security', + 'icon' => 'icons.icon-security', + 'route' => 'logout', + 'roles' => ['super_admin', 'admin', 'employee', 'user'], + ], + [ + 'title' => 'Team', + 'icon' => 'icons.icon-team', + 'route' => 'logout', + 'roles' => ['super_admin', 'admin', 'employee', 'user'], + ], + [ + 'title' => 'Settings', + 'icon' => 'icons.icon-settings', + 'route' => 'logout', + 'roles' => ['super_admin', 'admin', 'employee', 'user'], + ], + [ + 'title' => 'Logout', + 'icon' => 'icons.icon-logout', + 'route' => 'logout', + 'roles' => ['super_admin', 'admin', 'employee', 'user'], + ], + ] + ], +]; diff --git a/config/wire-elements-modal.php b/config/wire-elements-modal.php new file mode 100644 index 0000000..2950291 --- /dev/null +++ b/config/wire-elements-modal.php @@ -0,0 +1,52 @@ + false, + + /* + |-------------------------------------------------------------------------- + | Include JS + |-------------------------------------------------------------------------- + | + | Livewire UI will inject the required Javascript in your blade template. + | If you want to bundle the required Javascript you can set this to false + | and add `require('vendor/wire-elements/modal/resources/js/modal');` + | to your script bundler like webpack. + | + */ + 'include_js' => true, + + /* + |-------------------------------------------------------------------------- + | Modal Component Defaults + |-------------------------------------------------------------------------- + | + | Configure the default properties for a modal component. + | + | Supported modal_max_width + | 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl' + */ + 'component_defaults' => [ + 'modal_max_width' => '2xl', + + 'close_modal_on_click_away' => true, + + 'close_modal_on_escape' => true, + + 'close_modal_on_escape_is_forceful' => true, + + 'dispatch_close_event' => false, + + 'destroy_on_close' => false, + ], +]; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index be65c19..922788e 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -13,14 +13,17 @@ return new class extends Migration { Schema::create('users', function (Blueprint $table) { $table->id(); - $table->string('name')->nullable(); - $table->string('username')->unique(); + $table->string('name')->unique(); + $table->string('username')->nullable()->unique(); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->boolean('is_active')->default(true)->index(); $table->boolean('must_change_pw')->default(true)->index(); - $table->string('role', 32)->default('admin')->index(); // z.B. admin|user + $table->boolean('two_factor_enabled')->default(false); + $table->boolean('two_factor_email_enabled')->default(false); + $table->string('totp_secret')->nullable(); + $table->string('role', 32)->default('admin')->index(); $table->rememberToken(); $table->timestamps(); diff --git a/database/migrations/2025_09_27_153255_create_domains_table.php b/database/migrations/2025_09_27_153255_create_domains_table.php index 7345284..cc56f02 100644 --- a/database/migrations/2025_09_27_153255_create_domains_table.php +++ b/database/migrations/2025_09_27_153255_create_domains_table.php @@ -15,6 +15,7 @@ return new class extends Migration $table->id(); $table->string('domain', 191)->unique(); $table->boolean('is_active')->default(true)->index(); + $table->boolean('is_system')->default(false); $table->timestamps(); }); } diff --git a/database/migrations/2025_09_27_153311_create_mail_users_table.php b/database/migrations/2025_09_27_153311_create_mail_users_table.php index 4f71534..3cdee5e 100644 --- a/database/migrations/2025_09_27_153311_create_mail_users_table.php +++ b/database/migrations/2025_09_27_153311_create_mail_users_table.php @@ -13,17 +13,25 @@ return new class extends Migration { Schema::create('mail_users', function (Blueprint $table) { $table->id(); + + // legt FK + Index an $table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete(); + $table->string('localpart', 191); - $table->string('email', 191)->unique(); // z.B. user@example.com - $table->string('password_hash'); // für Dovecot: BLF-CRYPT (bcrypt) + $table->string('email', 191)->unique('mail_users_email_unique'); // genau EIN Unique-Index + $table->string('password_hash')->nullable(); // system-Accounts dürfen null sein + + $table->boolean('is_system')->default(false)->index(); // oft nach Systemkonten filtern $table->boolean('is_active')->default(true)->index(); $table->boolean('must_change_pw')->default(true)->index(); - $table->unsignedInteger('quota_mb')->default(0); // 0 = unbegrenzt + + $table->unsignedInteger('quota_mb')->default(0); // 0 = unlimited $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); - $table->index(['domain_id', 'localpart']); + // schneller Lookup pro Domain + Localpart UND verhindert Dubletten je Domain + $table->unique(['domain_id', 'localpart'], 'mail_users_domain_localpart_unique'); }); } diff --git a/database/migrations/2025_09_27_153347_create_dkim_keys_table.php b/database/migrations/2025_09_27_153347_create_dkim_keys_table.php index 48577bc..7419117 100644 --- a/database/migrations/2025_09_27_153347_create_dkim_keys_table.php +++ b/database/migrations/2025_09_27_153347_create_dkim_keys_table.php @@ -13,14 +13,15 @@ return new class extends Migration { Schema::create('dkim_keys', function (Blueprint $table) { $table->id(); - $table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete(); - $table->string('selector', 64)->default('mail')->index(); - $table->longText('private_key_pem'); // oder Pfad, wenn du lieber Dateien nutzt - $table->longText('public_key_txt'); // nur der Key-Body für DNS - $table->boolean('is_active')->default(true)->index(); + $table->unsignedBigInteger('domain_id'); + $table->string('selector', 64); + $table->longText('private_key_pem'); + $table->longText('public_key_txt'); + $table->boolean('is_active')->default(true); $table->timestamps(); $table->unique(['domain_id','selector']); + $table->foreign('domain_id')->references('id')->on('domains')->cascadeOnDelete(); }); } diff --git a/database/migrations/2025_10_05_120743_create_two_factor_methods_table.php b/database/migrations/2025_10_05_120743_create_two_factor_methods_table.php new file mode 100644 index 0000000..d682f80 --- /dev/null +++ b/database/migrations/2025_10_05_120743_create_two_factor_methods_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('method', ['totp','email']); // weitere später möglich + $table->string('secret')->nullable(); // z.B. TOTP-Secret (verschlüsselt) + $table->json('recovery_codes')->nullable(); + $table->boolean('enabled')->default(false); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id','method']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('two_factor_methods'); + } +}; diff --git a/database/migrations/2025_10_05_180725_create_two_factor_recovery_codes_table.php b/database/migrations/2025_10_05_180725_create_two_factor_recovery_codes_table.php new file mode 100644 index 0000000..cd1ead2 --- /dev/null +++ b/database/migrations/2025_10_05_180725_create_two_factor_recovery_codes_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('code_hash', 255); + $table->timestamp('used_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('two_factor_recovery_codes'); + } +}; diff --git a/database/migrations/2025_10_06_091010_create_spf_records_table.php b/database/migrations/2025_10_06_091010_create_spf_records_table.php new file mode 100644 index 0000000..979d84e --- /dev/null +++ b/database/migrations/2025_10_06_091010_create_spf_records_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('domain_id'); + $table->string('record_txt', 255); // z.B. "v=spf1 mx a ip4:1.2.3.4 -all" + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->foreign('domain_id')->references('id')->on('domains')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('spf_records'); + } +}; diff --git a/database/migrations/2025_10_06_091041_create_dmarc_records_table.php b/database/migrations/2025_10_06_091041_create_dmarc_records_table.php new file mode 100644 index 0000000..afe44bd --- /dev/null +++ b/database/migrations/2025_10_06_091041_create_dmarc_records_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('domain_id'); + $table->enum('policy', ['none','quarantine','reject'])->default('none'); + $table->string('rua')->nullable(); // "mailto:dmarc@domain.tld" + $table->string('ruf')->nullable(); + $table->unsignedInteger('pct')->default(100); + $table->string('record_txt', 255)->nullable(); // vorgerendert, optional + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->foreign('domain_id')->references('id')->on('domains')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('dmarc_records'); + } +}; diff --git a/database/migrations/2025_10_06_185027_create_tlsa_records_table.php b/database/migrations/2025_10_06_185027_create_tlsa_records_table.php new file mode 100644 index 0000000..80d10e0 --- /dev/null +++ b/database/migrations/2025_10_06_185027_create_tlsa_records_table.php @@ -0,0 +1,38 @@ +id(); + $table->id(); + $table->foreignId('domain_id')->constrained()->cascadeOnDelete(); + $table->string('service')->default('_25._tcp'); + $table->string('host'); + $table->tinyInteger('usage')->default(3); + $table->tinyInteger('selector')->default(1); + $table->tinyInteger('matching')->default(1); + $table->string('hash', 128); + $table->string('cert_path')->nullable(); + $table->timestamps(); + + $table->unique(['domain_id','service','host'], 'tlsa_domain_service_host_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tlsa_records'); + } +}; diff --git a/database/seeders/SystemDomainSeeder.php b/database/seeders/SystemDomainSeeder.php new file mode 100644 index 0000000..8599b6f --- /dev/null +++ b/database/seeders/SystemDomainSeeder.php @@ -0,0 +1,124 @@ +command->warn("BASE_DOMAIN ist 'example.com' – Seeder überspringt produktive Einträge."); + return; + } + + $systemSub = env('SYSTEM_SUB', 'system'); + $base = "{$systemSub}.{$base}"; + + // Domain anlegen/holen + $domain = Domain::firstOrCreate( + ['domain' => $base], + ['is_active' => true, 'is_system' => true] + ); + + // System Absender (no-reply) – ohne Passwort (kein Login) + MailUser::firstOrCreate( + ['email' => "no-reply@{$base}"], + [ + 'domain_id' => $domain->id, + 'localpart' => 'no-reply', + 'password_hash' => null, + 'is_active' => true, + 'is_system' => true, + 'must_change_pw' => false, + 'quota_mb' => 0, + ] + ); + + // DKIM – Key erzeugen, falls keiner aktiv existiert + if (! $domain->dkimKeys()->where('is_active', true)->exists()) { + [$privPem, $pubTxt] = $this->generateDkimKeyPair(); + $selector = 'mwl1'; // frei wählbar, z. B. rotierend später + + DkimKey::create([ + 'domain_id' => $domain->id, + 'selector' => $selector, + 'private_key_pem'=> $privPem, + 'public_key_txt' => $pubTxt, + 'is_active' => true, + ]); + + $this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$base}"); + } + + // SPF – einfachen Default bauen + $serverIp = env('SERVER_IP'); // optional vom Installer rein schreiben + $parts = ['v=spf1','mx','a']; + if ($serverIp) $parts[] = "ip4:{$serverIp}"; + $parts[] = '-all'; + $spf = implode(' ', $parts); + + SpfRecord::firstOrCreate( + ['domain_id' => $domain->id, 'record_txt' => $spf], + ['is_active' => true] + ); + + // DMARC – vorsichtig starten (p=none) + $rua = "mailto:dmarc@{$base}"; + $dmarc = DmarcRecord::firstOrCreate( + ['domain_id' => $domain->id, 'policy' => 'none'], + ['rua' => $rua, 'pct' => 100, 'record_txt' => "v=DMARC1; p=none; rua={$rua}; pct=100", 'is_active' => true] + ); + + $this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM eingetragen."); + $this->command->line("DNS-Hinweise:"); + $this->printDnsHints($domain); + } + + /** @return array{0:string privatePem,1:string publicTxt} */ + private function generateDkimKeyPair(): array + { + $res = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($res, $privateKeyPem); + $details = openssl_pkey_get_details($res); + // $details['key'] ist PEM, wir brauchen Base64 ohne Header/Footers + $pubDer = $details['key']; + // Public PEM zu "p=" Wert (reines Base64) normalisieren + $pubTxt = trim(preg_replace('/-----(BEGIN|END) PUBLIC KEY-----|\s+/', '', $pubDer)); + + return [$privateKeyPem, $pubTxt]; + } + + private function printDnsHints(Domain $domain): void + { + $base = $domain->domain; + $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); + if ($dkim) { + $this->command->line(" • DKIM TXT @ {$dkim->selector}._domainkey.{$base}"); + $this->command->line(" v=DKIM1; k=rsa; p={$dkim->public_key_txt}"); + } + + $spf = $domain->spf()->where('is_active', true)->latest()->first(); + if ($spf) { + $this->command->line(" • SPF TXT @ {$base}"); + $this->command->line(" {$spf->record_txt}"); + } + + $dmarc = $domain->dmarc()->where('is_active', true)->latest()->first(); + if ($dmarc) { + $this->command->line(" • DMARC TXT @ _dmarc.{$base}"); + $this->command->line(" " . ($dmarc->record_txt ?? $dmarc->renderTxt())); + } + } +} diff --git a/package-lock.json b/package-lock.json index 5e47680..4ffafc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "@phosphor-icons/web": "^2.1.2" + "@phosphor-icons/web": "^2.1.2", + "jquery": "^3.7.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -1725,6 +1726,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT" + }, "node_modules/laravel-echo": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.4.tgz", diff --git a/package.json b/package.json index b645f2f..133f876 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "vite": "^7.0.4" }, "dependencies": { - "@phosphor-icons/web": "^2.1.2" + "@phosphor-icons/web": "^2.1.2", + "jquery": "^3.7.1" } } diff --git a/resources/css/app.css b/resources/css/app.css index 0263077..c0f06c9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,7 +1,9 @@ @import 'tailwindcss'; -/*@import "@plugins/Toastra/src/message.css";*/ @import '../js/plugins/GlassToastra/style.css'; +@import "../fonts/BaiJamjuree/font.css"; +@import "../fonts/Space/font.css"; + @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @source '../**/*.blade.php'; @@ -10,6 +12,10 @@ @theme { --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --font-bai: 'Bai Jamjuree'; + --font-space: 'Space Age'; + --color-glass-bg: rgba(17, 24, 39, 0.55); --color-glass-border: rgba(255, 255, 255, 0.08); --color-glass-light: rgba(31, 41, 55, 0.4); @@ -18,30 +24,290 @@ --color-accent-600: #0891b2; /* cyan-600 */ --color-accent-700: #0e7490; /* cyan-700 */ --color-ring: rgba(34, 197, 94, .35); /* ein Hauch Grün im Focus-Glow */ + --sidebar-collapsed: 5.2rem; /* ~83px */ + --sidebar-expanded: 16rem; /* 256px */ + /* Optional: max-width Tokens */ + --max-w-sb-col: 5.2rem; + --max-w-sb-exp: 16rem; + } +@utility w-sb-col { + width: var(--sidebar-collapsed); +} +@utility w-sb-exp { + width: var(--sidebar-expanded); +} + +/* Max-width utilities, if du sie brauchst */ +@utility max-w-sb-col { + max-width: var(--max-w-sb-col); +} +@utility max-w-sb-exp { + max-width: var(--max-w-sb-exp); +} + + +@layer components { + /* Hauptkarte – entspricht dem helleren Glasslook deiner Server-Box */ + .mw-card { + @apply rounded-2xl p-5 md:p-6 + border border-white/10 + bg-white/5 backdrop-blur-xl + shadow-[0_20px_60px_-20px_rgba(0,0,0,.6),inset_0_1px_0_rgba(255,255,255,.06)]; + /* feiner Verlauf wie oben */ + background-image: radial-gradient(120% 120% at 100% 0%, rgba(56, 189, 248, .08) 0%, transparent 60%), + radial-gradient(140% 140% at 0% 120%, rgba(16, 185, 129, .06) 0%, transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .02)); + } + + /* Subkarte (kleinere Panels in einer Karte) */ + .mw-subcard { + @apply rounded-xl p-4 + border border-white/10 + bg-white/5 backdrop-blur-xl; + } + + .mw-title { + @apply text-white/90 font-semibold tracking-wide; + } + + .mw-subtle { + @apply text-white/60 text-sm; + } + + .mw-divider { + @apply border-t border-white/10 my-4; + } +} + +/* ============ BOOT-STATE: keine Sprünge ============ */ + +/* Während booting KEINE Transitionen */ +html[data-ui="booting"] * { + transition: none !important; +} + +/* Labels, Carets, Submenüs in der Sidebar während booting NICHT zeigen */ +html[data-ui="booting"] #sidebar .sidebar-label, +html[data-ui="booting"] #sidebar .sidebar-caret, +html[data-ui="booting"] #sidebar [data-submenu] { + display: none !important; +} + +/* Mobil: Sidebar standardmäßig offcanvas (wegschieben) */ +@media (max-width: 639.98px) { + #sidebar { + transform: translateX(-100%); + } +} + +/* Tablet: Sidebar standardmäßig RAIL (schmal) */ +@media (min-width: 640px) and (max-width: 1023.98px) { + #sidebar { + --sbw: 5.2rem; + width: var(--sbw); + max-width: var(--sbw); + } + + /* Nur im RAIL-Zustand ausblenden – falls du per JS .u-collapsed setzt */ + #sidebar.u-collapsed .sidebar-label, + #sidebar.u-collapsed .sidebar-caret, + #sidebar.u-collapsed [data-submenu] { + display: none !important; + } +} + +/* Desktop: Sidebar standardmäßig EXPANDED (breit) */ +@media (min-width: 1024px) { + #sidebar { + --sb-w: 16rem; + width: var(--sb-w); + max-width: var(--sb-w); + } +} + +/* ======== READY-STATE: Transitionen wieder an ========= */ + +/* Wenn JS fertig ist -> sanfte Transitions zulassen (ab jetzt darf’s animieren) */ +html[data-ui="ready"] #sidebar { + transition: width .2s ease, max-width .2s ease, transform .2s ease; +} + +/* Rail-Zustand per Klasse (dein JS schaltet diese Klassen weiter) */ +#sidebar.u-collapsed { + --sb-w: 5.2rem; + width: var(--sb-w); + max-width: var(--sb-w); +} + +#sidebar.ut-expanded { + --sb-w: 16rem; + width: var(--sb-w); + max-width: var(--sb-w); +} + +/* Rail: Labels/Carets/Submenüs IMMER ausblenden */ +#sidebar.u-collapsed .sidebar-label, +#sidebar.u-collapsed .sidebar-caret, +#sidebar.u-collapsed [data-submenu] { + display: none !important; +} + +/* Mobile Offcanvas: Klassen aus deinem JS */ +#sidebar.translate-x-0 { + transform: translateX(0); +} + +/* Sicherheitsnetz: [x-cloak] bleibt unsichtbar bis Alpine lädt */ +[x-cloak] { + display: none !important; +} + +/* === App Backdrop (türkis/glass) === */ +.app-backdrop { + position: fixed; + inset: 0; + z-index: -10; + pointer-events: none; + background: radial-gradient(1600px 900px at 85% -15%, rgba(34, 211, 238, .28), transparent 62%), + radial-gradient(1200px 800px at 10% 112%, rgba(16, 185, 129, .18), transparent 70%), + linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18); +} + +.primary-btn { + @apply relative inline-flex items-center justify-center rounded-xl + px-6 py-2.5 text-sm font-medium text-white transition-all + bg-gradient-to-r from-[rgba(34,211,238,0.35)] to-[rgba(16,185,129,0.35)] + border border-white/10 shadow-[0_0_25px_rgba(16,185,129,0.08)] + hover:from-[rgba(34,211,238,0.55)] hover:to-[rgba(16,185,129,0.55)] + hover:shadow-[0_0_20px_rgba(34,211,238,0.25)] + focus:outline-none focus:ring-2 focus:ring-[rgba(34,211,238,0.4)] + focus:ring-offset-0; +} + +/* === Layout Breite der Sidebar per Variable steuern === */ +:root { + --sb-w: 16rem; +} + +/* Basiswert (expanded) */ + +/* Header + Main bekommen die gleiche linke Einrückung */ +.header-shell, +.main-shell { + padding-left: var(--sb-w); + transition: padding-left .2s ease; +} + +/* ---------- Desktop (>=1024) ---------- */ +@media (min-width: 1024px) { + /* default expanded */ + .main-shell { + --sb-w: 16rem; + } + + .main-shell.rail { + --sb-w: 5.2rem; + } + + /* wenn per JS .rail gesetzt wird */ + /* Sidebar-Breite koppeln */ + #sidebar { + --sbw: 16rem; + } + + #sidebar.u-collapsed { + --sbw: 5.2rem; + } +} + +/* ---------- Tablet (640–1023) ---------- */ +@media (min-width: 640px) and (max-width: 1023.98px) { + .main-shell { + --sb-w: 5.2rem; + } + + /* standard rail */ + .main-shell.ut-expanded { + --sb-w: 16rem; + } + + /* bei expand */ + #sidebar { + --sbw: 5.2rem; + } + + #sidebar.ut-expanded { + --sbw: 16rem; + } +} + +/* ---------- Mobile (<640) ---------- */ +@media (max-width: 639.98px) { + .main-shell { + --sb-w: 0; + } + + /* off-canvas → kein Padding */ +} + +/* Sidebar selbst nimmt die variable Breite */ +#sidebar { + width: var(--sbw, 16rem); + max-width: var(--sbw, 16rem); + transition: width .2s ease, max-width .2s ease, transform .2s ease; +} + +/* Rail: Label, Carets, Submenüs ausblenden (kein Springen) */ +#sidebar.u-collapsed .sidebar-label { + display: none !important; +} + +#sidebar.u-collapsed .sidebar-caret { + display: none !important; +} + +#sidebar.u-collapsed [data-submenu] { + display: none !important; +} + +/* Anti-FOUC für Alpine */ +[x-cloak] { + display: none !important; +} + +/* Optional: beim First Paint Animationen unterdrücken (falls du body.no-animate setzt) */ +.no-animate * { + transition: none !important; +} /* Reusable utilities */ .glass-card { @apply bg-glass-bg/70 backdrop-blur-md border border-glass-border rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,.25)]; } + .glass-input { @apply bg-glass-light/50 border border-glass-border rounded-lg text-gray-100 placeholder-gray-400 - focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)] focus:border-cyan-500/60 + focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)] focus:border-cyan-500/60 transition-colors; } + .btn-primary { @apply inline-flex items-center justify-center px-4 py-2 rounded-lg text-white font-medium bg-gradient-to-b from-cyan-500 to-cyan-600 hover:from-cyan-400 hover:to-cyan-600 - focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)] + focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)] shadow-[inset_0_1px_0_rgba(255,255,255,.08),0_8px_20px_rgba(0,0,0,.25)]; } + .badge { @apply text-xs px-2 py-0.5 rounded-md border border-glass-border text-gray-300/80 bg-glass-light/40; } + .card-title { @apply text-gray-100/95 font-semibold tracking-wide; } + .card-subtle { @apply text-gray-300/70 text-sm leading-relaxed; } @@ -50,9 +316,11 @@ .input-error { @apply border-red-400/50 focus:border-red-400/70 focus:ring-red-400/30; } + .input-success { @apply border-emerald-400/50 focus:border-emerald-400/70 focus:ring-emerald-400/30; } + .input-disabled { @apply opacity-60 cursor-not-allowed; } @@ -61,9 +329,11 @@ .field-label { @apply block text-sm text-gray-300 mb-1; } + .field-error { @apply mt-1 text-xs text-red-300; } + .field-help { @apply mt-1 text-xs text-gray-400; } @@ -71,7 +341,7 @@ /* Select im gleichen Look wie .glass-input */ .glass-select { @apply bg-glass-light/50 border border-glass-border rounded-lg text-gray-100 - focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)] + focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)] focus:border-cyan-500/60 transition-colors; } @@ -79,11 +349,14 @@ .input-with-icon { @apply relative; } + .input-with-icon > .icon-left { @apply absolute inset-y-0 left-3 flex items-center text-gray-400; } + .input-with-icon > input { - @apply pl-10; /* Platz für Icon */ + @apply pl-10; + /* Platz für Icon */ } /* Karten-Interaktion (leichtes Hover-Lift) */ @@ -92,15 +365,16 @@ } /* Buttons – zusätzliche Varianten */ -.btn-ghost { +.ghost-btn { @apply inline-flex items-center justify-center px-4 py-2 rounded-lg text-gray-200 bg-white/5 border border-white/10 hover:bg-white/10 - focus:outline-none focus:ring-4 focus:ring-[var(--color-ring)]; + focus:outline-none focus:ring-0 focus:ring-[var(--color-ring)]; } -.btn-danger { + +.danger-btn { @apply inline-flex items-center justify-center px-4 py-2 rounded-lg text-white bg-gradient-to-b from-rose-500 to-rose-600 hover:from-rose-400 hover:to-rose-600 - focus:outline-none focus:ring-4 focus:ring-rose-400/40; + focus:outline-none focus:ring-0 focus:ring-rose-400/40; } /* Checkbox/Switch im Glas-Look */ @@ -114,3 +388,160 @@ .divider { @apply border-t border-glass-border/80 my-6; } + + +button { + cursor: pointer; +} + +.chip { + @apply flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 backdrop-blur px-3 py-2 hover:bg-white/10 transition; +} + +.badge { + @apply ml-1 rounded-full px-2 py-0.5 text-xs font-medium border border-white/10; +} + +@layer components { + .section-title { + @apply text-white/70 text-sm font-medium uppercase tracking-wide; + } + + .btn-surface { + @apply inline-flex items-center gap-2 rounded-lg border border-sky-400/20 bg-sky-600/20 + hover:bg-sky-600/30 text-sky-100 text-sm px-3 py-1.5 transition; + } + + .input-surface { + @apply bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white/90 placeholder-white/30; + } + + .checkbox-accent { + @apply h-4 w-4 rounded border border-white/20 bg-white/5 text-sky-400 focus:ring-0; + } + + .toggle-tile { + @apply flex items-center gap-3 p-3 rounded-xl border border-white/10 bg-white/5; + } + + .host-row { + @apply flex items-stretch rounded-xl overflow-hidden border border-white/10 bg-white/5; + } + + .host-prefix { + @apply px-2.5 flex items-center text-white/40 text-xs border-r border-white/10; + } + + .host-input { + @apply flex-1 bg-transparent px-3 py-2 outline-none text-white/90; + } + + .host-suffix { + @apply px-2.5 flex items-center text-white/50 bg-white/5 border-l border-white/10; + } + + .host-fqdn { + @apply text-xs text-white/60 mt-1 font-mono tabular-nums; + } + + .btn-primary { + @apply inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-3 py-1.5 text-sm text-white/80 hover:bg-white/[0.12] hover:text-white; + } + +} + +input[type="text"], +input[type="email"], +input[type="number"], +input[type="password"], +textarea, +select { + @apply outline-none transition + focus:border-sky-400/50 focus:ring-0 focus:ring-sky-400/20 +} + +@layer components { + .nx-card { + @apply rounded-2xl p-6 md:p-7 + bg-white/5 backdrop-blur-xl + border border-white/10 + shadow-[0_20px_60px_-20px_rgba(0,0,0,.6),inset_0_1px_0_rgba(255,255,255,.06)]; + } + + .nx-chip { + @apply inline-flex items-center px-3 py-1 rounded-full text-sm + bg-white/8 text-white/90 border border-white/10; + } + + .nx-subtle { + @apply text-white/80 leading-relaxed; + } + + .nx-input { + @apply w-full rounded-xl px-3.5 py-3 text-white/95 + bg-[rgba(12,18,28,.55)] + border border-white/10 + shadow-[inset_0_1px_0_rgba(255,255,255,.06)] + outline-none transition + focus:border-sky-400/50 focus:ring-0 focus:ring-sky-400/20; + } + + .nx-label { + @apply block text-xs text-white/70 mb-1; + } + + .nx-eye { + @apply absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-lg + hover:bg-white/5 focus:outline-none focus:ring-0 focus:ring-sky-400/20; + } + + .nx-check { + @apply h-4 w-4 rounded border-white/20 bg-white/10 + focus:ring-2 focus:ring-sky-400/30; + } + + .nx-btn { + @apply inline-flex items-center justify-center rounded-xl py-3 font-medium text-white + bg-gradient-to-r from-[#6d7cff] via-[#5aa7ff] to-[#39d0ff] + shadow-[inset_0_1px_0_rgba(255,255,255,.12),0_10px_30px_-8px_rgba(56,189,248,.45)] + hover:from-[#7e89ff] hover:to-[#46d7ff] + focus:outline-none focus:ring-0 focus:ring-sky-400/25; + } + + .nx-btn-ghost { + @apply rounded-xl px-4 py-2.5 text-white/85 border border-white/10 bg-white/5 + hover:bg-white/10 focus:outline-none focus:ring-0 focus:ring-sky-400/20; + } + + .nx-link { + @apply text-sky-300 hover:text-sky-200 transition; + } + + .nx-divider { + @apply relative text-center text-white/50 text-xs mt-7 mb-2; + } + + .nx-divider::before, .nx-divider::after { + content: ""; + @apply absolute top-1/2 w-[38%] h-px bg-white/10; + } + + .nx-divider::before { + left: 0; + } + + .nx-divider::after { + right: 0; + } + + .nx-alert { + @apply flex gap-3 items-start rounded-xl p-3.5 + border border-rose-400/25 bg-rose-400/10 text-rose-50; + } + + + hr, .hr { + border-color: var(--color-slate-700); + } + +} diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-200.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-200.woff2 new file mode 100644 index 0000000..25553f9 Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-200.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-200italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-200italic.woff2 new file mode 100644 index 0000000..8f8a3da Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-200italic.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2 new file mode 100644 index 0000000..d3db059 Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-300italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-300italic.woff2 new file mode 100644 index 0000000..6876b9f Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-300italic.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2 new file mode 100644 index 0000000..dfc55e1 Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-500italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-500italic.woff2 new file mode 100644 index 0000000..739978b Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-500italic.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2 new file mode 100644 index 0000000..0c1295a Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2 new file mode 100644 index 0000000..c17f7f6 Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2 new file mode 100644 index 0000000..e14827b Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-700italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-700italic.woff2 new file mode 100644 index 0000000..5587cdf Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-700italic.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2 new file mode 100644 index 0000000..7eb89d4 Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2 differ diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2 new file mode 100644 index 0000000..8d611b5 Binary files /dev/null and b/resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2 differ diff --git a/resources/fonts/BaiJamjuree/font.css b/resources/fonts/BaiJamjuree/font.css new file mode 100644 index 0000000..4e239f9 --- /dev/null +++ b/resources/fonts/BaiJamjuree/font.css @@ -0,0 +1,97 @@ +/* bai-jamjuree-200 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 200; + src: url('bai-jamjuree-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-200italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: italic; + font-weight: 200; + src: url('bai-jamjuree-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-300 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 300; + src: url('bai-jamjuree-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-300italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: italic; + font-weight: 300; + src: url('bai-jamjuree-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 400; + src: url('bai-jamjuree-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: italic; + font-weight: 400; + src: url('bai-jamjuree-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-500 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 500; + src: url('bai-jamjuree-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-500italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: italic; + font-weight: 500; + src: url('bai-jamjuree-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 600; + src: url('bai-jamjuree-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-600italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: italic; + font-weight: 600; + src: url('bai-jamjuree-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-700 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 700; + src: url('bai-jamjuree-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} +/* bai-jamjuree-700italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Bai Jamjuree'; + font-style: italic; + font-weight: 700; + src: url('bai-jamjuree-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + diff --git a/resources/fonts/Space/font.css b/resources/fonts/Space/font.css new file mode 100644 index 0000000..74bf901 --- /dev/null +++ b/resources/fonts/Space/font.css @@ -0,0 +1,7 @@ +@font-face { + font-family: 'space'; + src: url('space age.otf') format('opentype'), + url('space age.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} diff --git a/resources/fonts/Space/space age.otf b/resources/fonts/Space/space age.otf new file mode 100644 index 0000000..6b22db0 Binary files /dev/null and b/resources/fonts/Space/space age.otf differ diff --git a/resources/fonts/Space/space age.ttf b/resources/fonts/Space/space age.ttf new file mode 100644 index 0000000..e89b0fe Binary files /dev/null and b/resources/fonts/Space/space age.ttf differ diff --git a/resources/js/app.js b/resources/js/app.js index c329f0d..2a72da2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -4,7 +4,7 @@ import "@phosphor-icons/web/duotone"; import "@phosphor-icons/web/light"; import "@phosphor-icons/web/regular"; import "@phosphor-icons/web/bold"; - +import './components/sidebar.js'; // import '@plugins/Toastra'; import '@plugins/GlassToastra/toastra.glass.js' -import './utils/events.js'; +// import './utils/events.js'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index e67d5c1..fd2f2d4 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -4,3 +4,7 @@ window.axios = axios; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; import './webserver/websocket.js'; + +import $ from "jquery"; +window.$ = $; +window.jQuery = $; diff --git a/resources/js/components/sidebar.js b/resources/js/components/sidebar.js new file mode 100644 index 0000000..a1d7fa0 --- /dev/null +++ b/resources/js/components/sidebar.js @@ -0,0 +1,376 @@ +// // resources/js/components/sidebar.js +// +// var pathName = window.location.href; +// pathName = pathName.replace(/\/+$/, ''); // Remove last slash from string +// +// var url = window.location.href; +// var a = url.indexOf('?'); +// var b = url.substring(a); +// var c = url.replace(b, ''); +// url = c; +// +// $(document).ready(function () { +// const sidebar = $('#main'); // Hier solltest du die Sidebar per ID oder Klasse auswählen +// // Zustandsvariablen für die jeweiligen Modi: +// let desktopCollapsed = false; // false = Sidebar ist ausgeklappt, true = eingeklappt +// let tabletExpanded = false; // false = Sidebar ist standardmäßig geschlossen, true = geöffnet +// let mobileExpanded = false; // false = Sidebar ist ausgeblendet, true = vollständig ausgefahren +// +// +// // Funktion, die anhand der Fensterbreite den aktuellen Modus zurückgibt +// function getMode() { +// const w = $(window).width(); +// if (w < 640) { +// return 'mobile'; +// } else if (w < 1024) { +// return 'tablet'; +// } else { +// return 'desktop'; +// } +// } +// +// function closeAllMenus() { +// document.querySelectorAll('#sidebar [x-data]').forEach(el => { +// if (el.__x) { +// el.__x.$data.open = false; +// } +// }); +// } +// +// function applyMode() { +// sidebar.removeClass('u-collapsed ut-expanded m-expanded expanded'); +// const mode = getMode(); +// if (mode === 'desktop') { +// if (desktopCollapsed) { +// sidebar.addClass('u-collapsed'); +// closeAllMenus(); // <<< HIER +// } else { +// sidebar.addClass('expanded'); +// } +// } else if (mode === 'tablet') { +// if (tabletExpanded) { +// sidebar.addClass('ut-expanded expanded'); +// } else { +// closeAllMenus(); // <<< HIER +// } +// } else if (mode === 'mobile') { +// if (mobileExpanded) { +// sidebar.addClass('m-expanded expanded'); +// } else { +// closeAllMenus(); // <<< HIER +// } +// } +// } +// +// // Funktion, die alle toggle-Klassen entfernt und anhand des aktuellen Modus den Zustand anwendet +// // function applyMode() { +// // // Entferne alle Klassen, die du zur Steuerung verwendest +// // sidebar.removeClass('u-collapsed ut-expanded m-expanded expanded'); +// // const mode = getMode(); +// // if (mode === 'desktop') { +// // if (desktopCollapsed) { +// // sidebar.addClass('u-collapsed'); +// // } else { +// // sidebar.addClass('expanded'); +// // } +// // } else if (mode === 'tablet') { +// // if (tabletExpanded) { +// // sidebar.addClass('ut-expanded expanded'); +// // } +// // } else if (mode === 'mobile') { +// // if (mobileExpanded) { +// // sidebar.addClass('m-expanded expanded'); +// // } +// // } +// // } +// +// // Beim Klick auf den Toggle-Button wird je nach aktuellem Modus der entsprechende Zustand umgeschaltet +// $('.sidebar-toggle').on('click', function () { +// const mode = getMode(); +// if (mode === 'desktop') { +// desktopCollapsed = !desktopCollapsed; +// } else if (mode === 'tablet') { +// tabletExpanded = !tabletExpanded; +// } else if (mode === 'mobile') { +// mobileExpanded = !mobileExpanded; +// } +// applyMode(); +// }); +// +// applyMode(); +// +// $(window).resize(function () { +// applyMode(); +// desktopCollapsed = false +// tabletExpanded = false +// mobileExpanded = false +// }) +// }); + + +// document.addEventListener('DOMContentLoaded', () => { +// const sidebar = document.getElementById('sidebar'); +// const shell = document.getElementById('main'); +// if (!sidebar || !shell) return; +// +// const toggles = sidebar.querySelectorAll('.sidebar-toggle'); +// const secBtns = sidebar.querySelectorAll('.js-sec-toggle'); +// +// // state +// let desktopCollapsed = false; // >=1024 +// let tabletExpanded = false; // 640-1023 +// let mobileExpanded = false; // <640 offcanvas +// +// const getMode = () => { +// const w = window.innerWidth; +// if (w < 640) return 'mobile'; +// if (w < 1024) return 'tablet'; +// return 'desktop'; +// }; +// +// const closeAllMenus = () => { +// sidebar.querySelectorAll('[x-data]').forEach(el => { +// if (el.__x) el.__x.$data.open = false; +// }); +// }; +// +// const applyMobileTranslate = () => { +// if (getMode() === 'mobile') { +// if (mobileExpanded) { +// sidebar.classList.add('translate-x-0'); +// sidebar.classList.remove('-translate-x-full'); +// } else { +// sidebar.classList.add('-translate-x-full'); +// sidebar.classList.remove('translate-x-0'); +// } +// } else { +// sidebar.classList.remove('-translate-x-full'); +// sidebar.classList.add('translate-x-0'); +// } +// }; +// +// const applyMode = () => { +// // clean classes +// sidebar.classList.remove('u-collapsed','ut-expanded','m-expanded','expanded'); +// shell.classList.remove('rail','ut-expanded'); +// +// applyMobileTranslate(); +// +// const mode = getMode(); +// if (mode === 'desktop') { +// if (desktopCollapsed) { +// sidebar.classList.add('u-collapsed'); // rail visuals +// shell.classList.add('rail'); // narrow padding +// closeAllMenus(); +// } else { +// sidebar.classList.add('expanded'); +// // shell default (16rem) via :root --sb-w +// } +// } else if (mode === 'tablet') { +// if (tabletExpanded) { +// sidebar.classList.add('ut-expanded','expanded'); +// shell.classList.add('ut-expanded'); // 16rem padding +// } else { +// sidebar.classList.add('u-collapsed'); // rail visuals +// shell.classList.add('rail'); // 5.2rem padding +// closeAllMenus(); +// } +// } else { +// // mobile +// if (mobileExpanded) { +// sidebar.classList.add('m-expanded','expanded'); +// } else { +// closeAllMenus(); +// } +// // shell has no padding on mobile (CSS) +// } +// }; +// +// // Toggle buttons (footer + mobile top) +// toggles.forEach(btn => { +// btn.addEventListener('click', () => { +// const mode = getMode(); +// if (mode === 'desktop') { +// desktopCollapsed = !desktopCollapsed; +// } else if (mode === 'tablet') { +// tabletExpanded = !tabletExpanded; +// } else { +// mobileExpanded = !mobileExpanded; +// } +// applyMode(); +// }); +// }); +// +// // In RAIL: clicking a section expands sidebar first and opens submenu +// secBtns.forEach(btn => { +// btn.addEventListener('click', (e) => { +// const isRail = sidebar.classList.contains('u-collapsed'); +// if (!isRail) return; // Alpine handles toggle (open=!open) already +// +// // prevent Alpine's inline handler from running +// e.preventDefault(); +// e.stopPropagation(); +// +// const mode = getMode(); +// if (mode === 'desktop') { +// desktopCollapsed = false; // expand +// } else if (mode === 'tablet') { +// tabletExpanded = true; // expand +// } else { +// mobileExpanded = true; // show offcanvas +// } +// applyMode(); +// +// // open the correct submenu after expansion +// const xroot = btn.closest('[x-data]'); +// if (xroot && xroot.__x) { +// xroot.__x.$data.open = true; +// } +// }); +// }); +// +// // Resize: reset and re-apply +// window.addEventListener('resize', () => { +// desktopCollapsed = false; +// tabletExpanded = false; +// mobileExpanded = false; +// applyMode(); +// }); +// +// applyMode(); +// }); + +document.addEventListener('DOMContentLoaded', () => { + requestAnimationFrame(() => { + document.documentElement.setAttribute('data-ui', 'ready'); + }); + + // optional Anti-FOUC + document.body.classList.remove('no-animate'); + + const sidebar = document.getElementById('sidebar'); + const shell = document.getElementById('main'); + if (!sidebar || !shell) return; + + // WICHTIG: sowohl Footer-Toggle (in Sidebar) als auch Button im Header + const toggles = document.querySelectorAll('.sidebar-toggle'); + const secBtns = sidebar.querySelectorAll('.js-sec-toggle'); + + // state + let desktopCollapsed = false; // >=1024 + let tabletExpanded = false; // 640-1023 + let mobileExpanded = false; // <640 offcanvas + + const getMode = () => { + const w = window.innerWidth; + if (w < 640) return 'mobile'; + if (w < 1024) return 'tablet'; + return 'desktop'; + }; + + const closeAllMenus = () => { + sidebar.querySelectorAll('[x-data]').forEach(el => { + if (el.__x) el.__x.$data.open = false; + }); + }; + + const applyMobileTranslate = () => { + if (getMode() === 'mobile') { + if (mobileExpanded) { + sidebar.classList.add('translate-x-0'); + sidebar.classList.remove('-translate-x-full'); + } else { + sidebar.classList.add('-translate-x-full'); + sidebar.classList.remove('translate-x-0'); + } + } else { + sidebar.classList.remove('-translate-x-full'); + sidebar.classList.add('translate-x-0'); + } + }; + + const applyMode = () => { + // reset Klassen + sidebar.classList.remove('u-collapsed','ut-expanded','m-expanded','expanded'); + shell.classList.remove('rail','ut-expanded'); + + applyMobileTranslate(); + + const mode = getMode(); + if (mode === 'desktop') { + if (desktopCollapsed) { + sidebar.classList.add('u-collapsed'); + shell.classList.add('rail'); // -> CSS setzt --sb-w: 5.2rem + closeAllMenus(); + } else { + sidebar.classList.add('expanded'); + // shell bleibt default --sb-w:16rem + } + } else if (mode === 'tablet') { + if (tabletExpanded) { + sidebar.classList.add('ut-expanded','expanded'); + shell.classList.add('ut-expanded'); // -> --sb-w:16rem + } else { + sidebar.classList.add('u-collapsed'); + shell.classList.add('rail'); // -> --sb-w:5.2rem + closeAllMenus(); + } + } else { // mobile + if (mobileExpanded) { + sidebar.classList.add('m-expanded','expanded'); + } else { + closeAllMenus(); + } + // shell hat auf Mobile kein Padding (CSS) + } + }; + + // Toggle-Klick (Header + Sidebar) + toggles.forEach(btn => { + btn.addEventListener('click', () => { + const mode = getMode(); + if (mode === 'desktop') { + desktopCollapsed = !desktopCollapsed; + } else if (mode === 'tablet') { + tabletExpanded = !tabletExpanded; + } else { + mobileExpanded = !mobileExpanded; + } + applyMode(); + }); + }); + + // Wenn im Rail auf einen Abschnitt geklickt wird ⇒ erst expanden, dann Submenü öffnen + secBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + const isRail = sidebar.classList.contains('u-collapsed'); + if (!isRail) return; // Alpine macht open = !open + + e.preventDefault(); + e.stopPropagation(); + + const mode = getMode(); + if (mode === 'desktop') { + desktopCollapsed = false; + } else if (mode === 'tablet') { + tabletExpanded = true; + } else { + mobileExpanded = true; + } + applyMode(); + + const xroot = btn.closest('[x-data]'); + if (xroot && xroot.__x) xroot.__x.$data.open = true; + }); + }); + + // Resize → Zustand zurücksetzen, sauber neu anwenden + window.addEventListener('resize', () => { + desktopCollapsed = false; + tabletExpanded = false; + mobileExpanded = false; + applyMode(); + }); + + applyMode(); +}); diff --git a/resources/js/plugins/GlassToastra/style.css b/resources/js/plugins/GlassToastra/style.css index 8b9fcc9..0891f46 100644 --- a/resources/js/plugins/GlassToastra/style.css +++ b/resources/js/plugins/GlassToastra/style.css @@ -94,11 +94,32 @@ .tg-toast.tg-warning .tg-icon{ border-color: rgba(245,158,11,.25); } .tg-toast.tg-error .tg-icon{ border-color: rgba(239,68,68,.25); } -.tg-card{ transform: translateY(8px); opacity:0; } -.tg-card.tg-in{ animation: tgIn .28s ease forwards; } -.tg-out .tg-card{ animation: tgOut .25s ease forwards; } -@keyframes tgIn { to { opacity:1; transform: translateY(0); } } -@keyframes tgOut { to { opacity:0; transform: translateY(6px); } } +/* Startzustand */ +.tg-card { + opacity: 0; + transform: translateY(8px); + will-change: opacity, transform; +} + +/* Einblenden */ +.tg-card.tg-in { + animation: tgIn .28s ease-out forwards; +} + +/* Ausblenden */ +.tg-card.tg-out { + animation: tgOut .28s ease-in forwards; +} + +/* Keyframes */ +@keyframes tgIn { + from { opacity: 0; transform: translateY(8px) } + to { opacity: 1; transform: translateY(0) } +} +@keyframes tgOut { + from { opacity: 1; transform: translateY(0) } + to { opacity: 0; transform: translateY(8px) } +} .notification-badge { font-size: 11px; diff --git a/resources/js/plugins/GlassToastra/toastra.glass.js b/resources/js/plugins/GlassToastra/toastra.glass.js index f9e6f06..37e9d70 100644 --- a/resources/js/plugins/GlassToastra/toastra.glass.js +++ b/resources/js/plugins/GlassToastra/toastra.glass.js @@ -118,7 +118,7 @@
${o.badge ? `${String(o.badge).toUpperCase()}` : ''} - ${o.domain ? `
${o.domain}
` : ''} + ${o.domain ? `
${o.domain}
` : ''}
@@ -132,7 +132,7 @@ - ${o.message ? `

${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') -
+
@endsection +{{-- resources/views/auth/login.blade.php --}} +{{--@extends('layouts.app')--}} + +{{--@section('title', 'Login')--}} + +{{--@section('content')--}} +{{--
--}} + +{{--
--}} +{{-- --}}{{-- Header-Chip + Icon --}} +{{--
--}} +{{-- Erster Login--}} +{{-- --}} +{{--
--}} + +{{--

--}} +{{-- Melde dich mit dem einmaligen Bootstrap-Konto an, um den Setup-Wizard zu starten.--}} +{{--

--}} + +{{-- --}}{{-- Fehler (optional) --}} +{{-- @if(session('error'))--}} +{{--
--}} +{{-- --}} +{{--
--}} +{{--

Anmeldung fehlgeschlagen

--}} +{{--

{{ session('error') }}

--}} +{{--
--}} +{{--
--}} +{{-- @endif--}} + +{{-- --}}{{-- Formular --}} +{{--
--}} +{{-- @csrf--}} + +{{-- --}} +{{-- --}} + +{{-- --}} +{{--
--}} +{{-- --}} +{{-- --}} +{{--
--}} + +{{--
--}} +{{-- --}} +{{-- Zugang zurücksetzen--}} +{{--
--}} + +{{-- --}} +{{--
--}} + +{{-- --}}{{-- Optional: Provider-Buttons --}} +{{-- --}}{{--
oder verbinden via
--}} +{{--
--}} +{{-- --}} +{{-- --}} +{{--
--}} +{{--
--}} +{{--
--}} +{{--@endsection--}} diff --git a/resources/views/auth/signup.blade.php b/resources/views/auth/signup.blade.php new file mode 100644 index 0000000..0f6c965 --- /dev/null +++ b/resources/views/auth/signup.blade.php @@ -0,0 +1,27 @@ +@extends('layouts.app') + +@section('title', 'Konto erstellen') + +@section('content') +{{--
--}} +{{--
--}} +{{--
--}} +{{-- Neu hier?--}} +{{-- --}} +{{--
--}} + +{{--

--}} +{{-- Erstelle ein Konto, um mit MailWolt zu starten.--}} +{{--

--}} + +{{-- --}} +{{--
--}} +{{--
--}} + +
+ +
+ +@endsection diff --git a/resources/views/components/chip-toggle.blade.php b/resources/views/components/chip-toggle.blade.php new file mode 100644 index 0000000..8cf1ad5 --- /dev/null +++ b/resources/views/components/chip-toggle.blade.php @@ -0,0 +1,19 @@ +@props([ + 'label', + 'value', + 'model' => null, +]) + +@php + $isActive = collect($attributes->wire('model')->value())->contains($value); +@endphp + + diff --git a/resources/views/components/icons/icon-logo-circle.blade.php b/resources/views/components/icons/icon-logo-circle.blade.php new file mode 100644 index 0000000..04ed4e5 --- /dev/null +++ b/resources/views/components/icons/icon-logo-circle.blade.php @@ -0,0 +1,17 @@ +merge(['class' => 'size-6']) }} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 85 86" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + + + + + + + + + diff --git a/resources/views/components/icons/icon-logo.blade.php b/resources/views/components/icons/icon-logo.blade.php new file mode 100644 index 0000000..5ae28c0 --- /dev/null +++ b/resources/views/components/icons/icon-logo.blade.php @@ -0,0 +1,13 @@ +merge(['class' => 'size-6']) }} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 37 66" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + + + + + + + + + + diff --git a/resources/views/components/page/pills.blade.php b/resources/views/components/page/pills.blade.php new file mode 100644 index 0000000..fa37411 --- /dev/null +++ b/resources/views/components/page/pills.blade.php @@ -0,0 +1,16 @@ +@props(['items' => [], 'active' => request()->url()]) + +
+ @foreach ($items as $item) + @php + $isActive = $active === $item['href']; + @endphp + + {{ $item['label'] }} + + @endforeach +
diff --git a/resources/views/components/partials/header.blade.php b/resources/views/components/partials/header.blade.php new file mode 100644 index 0000000..04bfc23 --- /dev/null +++ b/resources/views/components/partials/header.blade.php @@ -0,0 +1,97 @@ +{{--resources/views/components/partials/header.blade.php--}} +
+ + +
diff --git a/resources/views/components/partials/sidebar.blade.php b/resources/views/components/partials/sidebar.blade.php new file mode 100644 index 0000000..77998e4 --- /dev/null +++ b/resources/views/components/partials/sidebar.blade.php @@ -0,0 +1,249 @@ +{{----}} +{{--
--}} +{{-- --}} + +{{--
--}} +{{-- --}} + +{{-- --}} +{{-- --}} +{{-- --}} +{{--
--}} +{{-- --}} +{{--
--}} +{{-- --}} +{{--
--}} +{{--
--}} +{{--
--}} +{{----}} + +{{-- resources/views/components/partials/sidebar.blade.php --}} + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 924ddbb..afe3dc8 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -3,41 +3,125 @@ - +{{-- --}} @yield('title', config('app.name')) - @vite(['resources/css/app.css','resources/js/app.js']) + + @vite(['resources/css/app.css']) @livewireStyles - -
-
-{{-- Optional: Header/Branding oben --}} -
-
-
FM
- MailWolt - @env('local') - dev - @endenv + + +
+ +
+ + +
+
+ +
+ +
+ @yield('content') +
- @isset($setupPhase) -
Setup-Phase: {{ $setupPhase }}
- @endisset -
- -{{-- Seite: immer auf volle Höhe und zentriert --}} -
-{{-- --}} -
- @yield('content') -
+ +@vite(['resources/js/app.js']) @livewireScripts +@livewire('wire-elements-modal') +{{----}} +{{--
--}} +{{--
--}} +{{--
--}} +{{----}} +{{--
--}} +{{--
--}} +{{--
--}} +{{--
FM
--}} +{{-- MailWolt--}} +{{-- @env('local')--}} +{{-- dev--}} +{{-- @endenv--}} +{{--
--}} +{{-- @isset($setupPhase)--}} +{{--
Setup-Phase: {{ $setupPhase }}
--}} +{{-- @endisset--}} +{{--
--}} + +{{-- --}}{{----}}{{-- Seite: immer auf volle Höhe und zentriert --}} +{{--
--}} +{{--
--}} +{{-- @yield('content')--}} +{{--
--}} +{{--
--}} + +{{----}} +{{--@vite(['resources/js/app.js'])--}} +{{--@livewireScripts--}} +{{--
--}} +{{-- --}} +{{-- --}} +{{--
--}} +{{-- --}} +{{--
--}} +{{--
--}} +{{-- @yield("content")--}} +{{--
--}} +{{--
--}} +{{----}} +{{--@vite(['resources/js/app.js'])--}} +{{--@livewireScripts--}} + +{{----}} diff --git a/resources/views/livewire/auth/login-form.blade.php b/resources/views/livewire/auth/login-form.blade.php index 2d7ab93..f354c00 100644 --- a/resources/views/livewire/auth/login-form.blade.php +++ b/resources/views/livewire/auth/login-form.blade.php @@ -1,59 +1,105 @@ -
-
-

Erster Login

-

+

+ +
+
+ Erster Login + +
+ +

Melde dich mit dem einmaligen Bootstrap-Konto an, um den Setup-Wizard zu starten.

- @if ($error) -
- + +
+
+ +
+
+ + + +
+ @if(\App\Models\Setting::signupAllowed()) +
+ Noch keinen Account? Zur Registiereung +
+ @endif
+ diff --git a/resources/views/livewire/auth/signup-form.blade.php b/resources/views/livewire/auth/signup-form.blade.php new file mode 100644 index 0000000..75fd186 --- /dev/null +++ b/resources/views/livewire/auth/signup-form.blade.php @@ -0,0 +1,124 @@ +
+ @if ($successMessage) +
+
+
+ +
+
+

Signup erfolgreich

+

{{ session('signup_success') }}

+ + Zum Login + + +
+ +
+
+ @endif + +
+
+ Neu hier? + +
+ +

+ Erstelle ein Konto, um mit MailWolt zu starten. +

+ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ +
+ + +
+ @error('password')

{{ $message }}

@enderror +
+ +
+ +
+ + +
+
+ +
+ + @error('accept')

{{ $message }}

@enderror +
+ + + + {{-- Fehler/Success Flash --}} + @if (session('error')) +
+ +
+

Registrierung fehlgeschlagen

+

{{ session('error') }}

+
+
+ @endif +
+ + {{-- kleines clientseitiges Toggle, löst kein Livewire-Event aus --}} + +
+
diff --git a/resources/views/livewire/ping-button.blade.php b/resources/views/livewire/ping-button.blade.php new file mode 100644 index 0000000..77ec890 --- /dev/null +++ b/resources/views/livewire/ping-button.blade.php @@ -0,0 +1,10 @@ +{{-- resources/views/livewire/test/ping-button.blade.php --}} +
+ +

Klick → Event cert.ping auf Channel system.tasks.

+ +
+ +
diff --git a/resources/views/livewire/setup/wizard.blade.php b/resources/views/livewire/setup/wizard.blade.php index 87d81f9..1d90cf3 100644 --- a/resources/views/livewire/setup/wizard.blade.php +++ b/resources/views/livewire/setup/wizard.blade.php @@ -1,4 +1,5 @@
+
@if ($step === 1) diff --git a/resources/views/livewire/ui/dashboard/domains-panel.blade.php b/resources/views/livewire/ui/dashboard/domains-panel.blade.php new file mode 100644 index 0000000..d5813ee --- /dev/null +++ b/resources/views/livewire/ui/dashboard/domains-panel.blade.php @@ -0,0 +1,35 @@ +{{-- resources/views/livewire/ui/dashboard/domains-panel.blade.php --}} +
+ {{-- Header in der Box --}} +
+
+ + Domains +
+
+ + {{-- FLACHE LISTE: keine inneren Glass-Karten mehr --}} +
    + @foreach($this->domains_with_badges as $domain) +
  • +
    + + + + {{ $domain['name'] }} +
    + +
    + {{-- Badge (kommt fertig aus der Klasse) – kleiner & flacher --}} + + + {{ $domain['cert_label'] }} + + + +
    +
  • + @endforeach +
+
diff --git a/resources/views/livewire/ui/dashboard/health-card.blade.php b/resources/views/livewire/ui/dashboard/health-card.blade.php new file mode 100644 index 0000000..b2e2935 --- /dev/null +++ b/resources/views/livewire/ui/dashboard/health-card.blade.php @@ -0,0 +1,210 @@ +
+ {{-- Header --}} +
+

Dienste & Status

+ aktualisiert: {{ $updatedAtHuman ?? '–' }} +
+ + {{-- CPU / RAM / Load / Uptime --}} +
+ {{-- CPU --}} +
+
+
+
+ + CPU +
+
+
{{ is_numeric($cpuPercent) ? $cpuPercent.'%' : '–' }}
+
+
+
+ @foreach($cpuSeg as $cls) +
+ @endforeach +
+
+
+ + {{-- RAM --}} +
+
+
+
+ + RAM +
+
+
{{ is_numeric($ramPercent) ? $ramPercent.'%' : '–' }}
+ @if($ramSummary) +
{{ $ramSummary }}
+ @endif +
+
+
+ @foreach($ramSeg as $cls) +
+ @endforeach +
+
+
+ + {{-- Load --}} +
+
+
+
+ + Load +
+
+
{{ $loadBadgeText }}
+
+ +
+
+ @foreach($loadDots as $d) +
+ + {{ $d['label'] }} +
+ @endforeach +
+
+
+ + {{-- Uptime --}} +
+
+
+
+ + Uptime +
+
+
{{ $uptimeText ?? '–' }}
+
+ +
+ @foreach($uptimeChips as $c) + + {{ $c['v'] }} {{ $c['u'] }} + + @endforeach +
+
+
+ + {{-- Dienste & Storage: kompakt & bündig --}} +
+ {{-- Dienste kompakt --}} +
+
+
+ + Dienste +
+ + systemctl / TCP + +
+
    + @forelse($servicesCompact as $s) +
  • +
    +
    +
    + +
    +
    {{ $s['label'] }}
    +
    +
    + @if($s['hint']) +
    {{ $s['hint'] }}
    + @endif +
    + + {{ $s['pillText'] }} + +
    +
  • + @empty +
  • Keine Daten.
  • + @endforelse +
+
+ + + {{-- 2-Spalten Abschnitt: links Dienste, rechts Storage --}} +
+ {{-- Kopf: Titel + Link oben links --}} +
+
+ + Storage +
+ + Details + +
+ {{-- Inhalt: Donut links, Zahlen rechts – stacked auf kleineren Screens --}} +
+ {{-- Donut --}} +
+
+ {{-- Innerer grauer Kreis --}} +
+ + {{-- Prozentanzeige im Zentrum – leicht kleiner & feiner --}} +
+
+ {{ $diskCenterText['percent'] }} +
+
+ {{ $diskCenterText['label'] }} +
+
+ + {{-- Segment-Ring – größerer Abstand zum Kreis --}} + @foreach($diskSegments as $seg) + + + + @endforeach +
+
+ + {{-- Zahlen rechts (kompakter Satz) --}} +
+
+
+
Gesamt
+
+ {{ is_numeric($diskTotalGb) ? $diskTotalGb.' GB' : '–' }} +
+
+
+
Genutzt
+
+ {{ is_numeric($diskUsedGb) ? $diskUsedGb.' GB' : '–' }} +
+
+
+
Frei
+
+ {{ is_numeric($diskFreeGb) ? $diskFreeGb.' GB' : '–' }} +
+
+
+
+
+
+
+
diff --git a/resources/views/livewire/ui/dashboard/recent-logins-table.blade.php b/resources/views/livewire/ui/dashboard/recent-logins-table.blade.php new file mode 100644 index 0000000..21c74dd --- /dev/null +++ b/resources/views/livewire/ui/dashboard/recent-logins-table.blade.php @@ -0,0 +1,37 @@ +{{-- resources/views/livewire/ui/dashboard/recent-logins-table.blade.php --}} +
+
+
+ + Letzte Anmeldungen +
+
+ +
+ + + + + + + + + + @foreach($rows as $r) + + + + + + @endforeach + +
BenutzerIP-AdresseZeitpunkt
+
+ + {{ strtoupper(substr($r['user'],0,1)) }} + + {{ $r['user'] }} +
+
{{ $r['ip'] }}{{ $r['time'] }}
+
+
diff --git a/resources/views/livewire/ui/dashboard/top-bar.blade.php b/resources/views/livewire/ui/dashboard/top-bar.blade.php new file mode 100644 index 0000000..b8d3eb6 --- /dev/null +++ b/resources/views/livewire/ui/dashboard/top-bar.blade.php @@ -0,0 +1,50 @@ +{{-- Responsive Topbar: Chips links (grid), IP rechts (stacked) --}} +
+ + {{-- Chips: auf Mobile 1–2 Spalten Grid, ab lg wieder flex/wrap --}} +
+ {{-- Domains --}} +
+ + Domains + {{ $domainsCount ?? 0 }} +
+ + {{-- Warnungen --}} +
+ + Warnungen + {{ $warningsCount ?? 0 }} +
+ + {{-- Updates --}} +
+ + Updates + {{ $updatesCount ?? 0 }} +
+
+ + {{-- IPs: auf Mobile unter die Chips, ab md rechtsbündig; mono, kompakt --}} +
+
+ + {{ $ipv4 ?? '–' }} +
+ + @if(!empty($ipv6) && $ipv6 !== '–') +
+ + {{ $ipv6 }} +
+ @endif +
+
diff --git a/resources/views/livewire/ui/domain/domain-dns-list.blade.php b/resources/views/livewire/ui/domain/domain-dns-list.blade.php new file mode 100644 index 0000000..43495d5 --- /dev/null +++ b/resources/views/livewire/ui/domain/domain-dns-list.blade.php @@ -0,0 +1,47 @@ +
+ + + + + @foreach($domains as $d) +
+ + +
+ {{ $d->domain }} +
+ + +
+ + {{ $d->is_active ? 'aktiv' : 'inaktiv' }} + +
+ + +
+ + {{ $d->is_system ? 'System' : 'Kunde' }} + +
+ + +
+ +
+
+ @endforeach +
diff --git a/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php b/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php new file mode 100644 index 0000000..c3a5556 --- /dev/null +++ b/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php @@ -0,0 +1,102 @@ +{{-- resources/views/livewire/domains/dns-assistant-modal.blade.php --}} +
+ + + TTL: {{ $ttl }} + + +
+
+

DNS-Einträge

+

+ Setze die folgenden Records in deiner DNS-Zone für + {{ $base }}. +

+
+ + {{-- GRID: links (Mail-Records), rechts (Infrastruktur) --}} +
+ {{-- Mail-Records --}} +
+
+ Step 1 + Mail-Records + + System-Absenderdomain + +
+ +
+ @foreach ($dynamic as $r) +
+
+
+ {{ $r['type'] }} + {{ $r['name'] }} +
+
+ +
+
+
+
{{ $r['value'] }}
+ @if(!empty($r['helpUrl'])) + + {{ $r['helpLabel'] }} + + @endif +
+
+ @endforeach +
+
+ + {{-- Globale Infrastruktur --}} +
+
+ Step 2 + Globale Infrastruktur (MTA-Host) + gilt für alle Domains +
+ +
+ @foreach ($static as $r) +
+
+
+ {{ $r['type'] }} + {{ $r['name'] }} +
+
+ +
+
+
+
{{ $r['value'] }}
+
+
+ @endforeach +
+
+
+ +
+ +
+
+
diff --git a/resources/views/livewire/ui/security/account-security-form.blade.php b/resources/views/livewire/ui/security/account-security-form.blade.php new file mode 100644 index 0000000..61ca6cd --- /dev/null +++ b/resources/views/livewire/ui/security/account-security-form.blade.php @@ -0,0 +1,233 @@ +{{-- Account Security – 2/3 : 1/3 Layout --}} +
+ + {{-- ========== LEFT (2/3) ========== --}} +
+ + {{-- Konto / Profil --}} +
+
+
+ + Konto-Informationen +
+ +
+ +
+ {{-- Name --}} +
+ + +

Wird in Übersichten und Benachrichtigungen angezeigt.

+
+ + {{-- Benutzername --}} +
+ + +

Kurzname für Login & UI.

+
+ + {{-- Maskierte E-Mail – nur Anzeige --}} +
+ +
+ {{ $maskedEmail }} +
+

Änderung erfolgt rechts in „E-Mail ändern“.

+
+
+
+ + {{-- Zwei-Faktor-Authentifizierung --}} +
+
+
+ + Zwei-Faktor-Authentifizierung +
+ + + {{ $userHas2fa ? '2FA aktiv' : '2FA inaktiv' }} + +
+ +
+ {{-- TOTP --}} +
+
+ +
+
Google Authenticator (TOTP)
+
+ Einmalcodes via Authenticator-App. + @if($totpActive) + aktiv + @else + nicht eingerichtet + @endif +
+
+
+ +
+ @if($totpActive) + + @else + + @endif +
+
+ + {{-- E-Mail-Code (Fallback/Alternative) --}} +
+
+ +
+
E-Mail-Code
+
+ Einmalcode per E-Mail. + @if($email2faActive) + aktiv + @else + nicht eingerichtet + @endif +
+
+
+ + +
+ + {{-- Recovery-Codes (optional, wenn du das implementierst) --}} +
+
+
Recovery-Codes
+
Einmal nutzbare Notfallcodes. Sicher aufbewahren.
+
+ +
+
+
+
+ + {{-- ========== RIGHT (1/3) ========== --}} +
+ + {{-- Passwort ändern --}} +
+
+ + Passwort ändern +
+ +
+
+ + +
+
+ + +
+ + @error('new_password')

{{ $message }}

@enderror + @error('new_password_confirmation')

{{ $message }}

@enderror +
+
+ + {{-- E-Mail ändern (editierbar) --}} +
+
+ + E-Mail ändern +
+ +
+
+ + +
+
+ + +
+ + +

Wir senden dir einen Bestätigungslink an die neue Adresse.

+ @error('email_current')

{{ $message }}

@enderror + @error('email_new')

{{ $message }}

@enderror +
+
+ + {{-- Anmeldungen & Geräte --}} +
+
+
+ + Anmeldungen & Geräte +
+ +
+ + @if(empty($sessions)) +
Keine Sitzungen gefunden.
+ @else +
    + @foreach($sessions as $s) +
  • +
    + {{ $s['device'] }} · {{ $s['ip'] }} + — {{ $s['seen'] }} +
    + @if(!$s['current']) + + @else + Aktuelle Sitzung + @endif +
  • + @endforeach +
+ @endif +
+ +
+
diff --git a/resources/views/livewire/ui/security/audit-logs-table.blade.php b/resources/views/livewire/ui/security/audit-logs-table.blade.php new file mode 100644 index 0000000..6a52160 --- /dev/null +++ b/resources/views/livewire/ui/security/audit-logs-table.blade.php @@ -0,0 +1,3 @@ +
+ {{-- If your happiness depends on money, you will never be happy with yourself. --}} +
diff --git a/resources/views/livewire/ui/security/fail2-ban-form.blade.php b/resources/views/livewire/ui/security/fail2-ban-form.blade.php new file mode 100644 index 0000000..7a4f210 --- /dev/null +++ b/resources/views/livewire/ui/security/fail2-ban-form.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Do your work, then step back. --}} +
diff --git a/resources/views/livewire/ui/security/modal/email2fa-setup-modal.blade.php b/resources/views/livewire/ui/security/modal/email2fa-setup-modal.blade.php new file mode 100644 index 0000000..6c81a6e --- /dev/null +++ b/resources/views/livewire/ui/security/modal/email2fa-setup-modal.blade.php @@ -0,0 +1,51 @@ +
+
+

E-Mail-2FA einrichten

+ +
+ +

+ Wir senden einen 6-stelligen Bestätigungscode an deine aktuelle Account-E-Mail. + Gib ihn unten ein, um E-Mail-2FA zu aktivieren. +

+ + {{-- Senden --}} +
+ +
+ + {{-- Code --}} +
+ + +
+ +
+ @if($alreadyActive) + + @endif + +
+
diff --git a/resources/views/livewire/ui/security/modal/recovery-codes-modal.blade.php b/resources/views/livewire/ui/security/modal/recovery-codes-modal.blade.php new file mode 100644 index 0000000..fb9d382 --- /dev/null +++ b/resources/views/livewire/ui/security/modal/recovery-codes-modal.blade.php @@ -0,0 +1,82 @@ +
+ {{-- Header --}} +
+
+ +

Recovery-Codes

+
+ +
+ + @if(empty($plainCodes)) + {{-- KEINE Klartextcodes sichtbar (entweder noch nie erzeugt ODER bereits vorhanden) --}} +
+ @if($hasExisting) +
+ +
+ Für deinen Account existieren bereits Recovery-Codes. + Aus Sicherheitsgründen können sie nicht erneut angezeigt werden. + Du kannst sie jedoch neu erzeugen (rotieren). +
+
+ @else +
+ Recovery-Codes sind Einmal-Notfallcodes, falls du z. B. den Zugriff auf deine 2FA-App verlierst. + Sie werden nur direkt nach der Erstellung angezeigt – bitte + speichere oder drucke sie sofort. +
+ @endif +
+ +
+ + +
+ @else + {{-- EINMALIGE ANZEIGE NACH GENERIERUNG --}} +
+ Hier sind deine neuen Recovery-Codes. Sie werden nur jetzt angezeigt. + Bewahre sie offline & sicher auf. +
+ +
+
+ @foreach($plainCodes as $c) +
+ {{ chunk_split($c, 5, ' ') }} +
+ @endforeach +
+
+ +
+ + + + + +
+ +
+ Tipp: Aktiviere TOTP als Hauptmethode, nutze E-Mail-Codes als Fallback und drucke diese Codes aus. +
+ @endif +
diff --git a/resources/views/livewire/ui/security/modal/totp-setup-modal.blade.php b/resources/views/livewire/ui/security/modal/totp-setup-modal.blade.php new file mode 100644 index 0000000..19d3051 --- /dev/null +++ b/resources/views/livewire/ui/security/modal/totp-setup-modal.blade.php @@ -0,0 +1,94 @@ +
+ {{-- Header --}} +
+
+

TOTP einrichten

+

+ Scanne den QR-Code mit deiner Authenticator-App und gib den 6-stelligen Code zur Bestätigung ein. +

+
+ +
+ + {{-- Step 1 --}} +
+ Step 1 + QR-Code scannen +
+ +
+
+ {{-- QR: perfectly square --}} +
+ TOTP QR +
+ + {{-- Secret + copy (stacked, readable) --}} +
+

Kannst du nicht scannen?

+

Gib stattdessen diesen Secret-Key ein:

+ +
+ +
+ +
+
+
+ + {{-- Step 2 --}} +
+ Step 2 + Bestätigungscode eingeben +
+ + {{-- 6 small boxes, centered --}} +
+ @for($i=0;$i<6;$i++) + + @endfor +
+ + {{-- Footer: hint + CTA --}} +
+

+ Achte darauf, dass die Gerätezeit korrekt ist. +

+{{-- --}} + + +{{-- --}} +
+
diff --git a/resources/views/livewire/ui/security/rspamd-form.blade.php b/resources/views/livewire/ui/security/rspamd-form.blade.php new file mode 100644 index 0000000..6a52160 --- /dev/null +++ b/resources/views/livewire/ui/security/rspamd-form.blade.php @@ -0,0 +1,3 @@ +
+ {{-- If your happiness depends on money, you will never be happy with yourself. --}} +
diff --git a/resources/views/livewire/ui/security/ssl-certificates-table.blade.php b/resources/views/livewire/ui/security/ssl-certificates-table.blade.php new file mode 100644 index 0000000..d5f5aa4 --- /dev/null +++ b/resources/views/livewire/ui/security/ssl-certificates-table.blade.php @@ -0,0 +1,3 @@ +
+ {{-- The best athlete wants his opponent at his best. --}} +
diff --git a/resources/views/livewire/ui/security/tls-ciphers-form.blade.php b/resources/views/livewire/ui/security/tls-ciphers-form.blade.php new file mode 100644 index 0000000..cdda2ce --- /dev/null +++ b/resources/views/livewire/ui/security/tls-ciphers-form.blade.php @@ -0,0 +1,3 @@ +
+ {{-- In work, do what you enjoy. --}} +
diff --git a/resources/views/livewire/ui/system/domains-ssl-form.blade.php b/resources/views/livewire/ui/system/domains-ssl-form.blade.php new file mode 100644 index 0000000..cfebb84 --- /dev/null +++ b/resources/views/livewire/ui/system/domains-ssl-form.blade.php @@ -0,0 +1,255 @@ +{{-- resources/views/livewire/ui/system/domains-ssl-form.blade.php --}} +
+ + {{-- DOMAINS & SSL --}} +
+
+
+ + Domains & SSL +
+ +
+ + + + @error('base_domain')

{{ $message }}

@enderror + +
+ {{-- UI --}} +
+ +
+ +
+ .{{ $base_domain }} +
+
+
{{ $this->ui_host }}
+ @error('ui_sub')

{{ $message }}

@enderror +
+ + {{-- Webmail --}} +
+ +
+ +
+ .{{ $base_domain }} +
+
+
{{ $this->webmail_host }}
+ @error('webmail_sub')

{{ $message }}

@enderror +
+ + {{-- MTA --}} +
+ +
+ +
+ .{{ $base_domain }} +
+
+
{{ $this->mta_host }}
+ @error('mta_sub')

{{ $message }}

@enderror +
+
+
+ + {{-- TLS / Redirect --}} +
+
+
+ + TLS / Redirect +
+ +
+ + @php $tile = 'flex items-center gap-3 h-12 px-4 rounded-xl border border-white/10 bg-white/[0.04] whitespace-nowrap'; @endphp +
+ + + +
+
+ + {{-- ACME --}} +
+
+
+ + ACME (Let’s Encrypt) +
+ +
+ +
+
+ + + @error('acme_contact')

{{ $message }}

@enderror +
+
+ + + @error('acme_env')

{{ $message }}

@enderror +
+
+ + + @error('acme_challenge')

{{ $message }}

@enderror +
+
+
+ + {{-- MTA-STS --}} + + {{-- MTA-STS --}} +
+
+
+ + MTA-STS +
+ +
+ + {{-- Reihe 1 --}} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {{-- Reihe 2: MX-Liste --}} +
+ + @foreach($mta_sts_mx as $idx => $value) +
+ + +
+ @endforeach + +
+ DNS TXT: {{ $this->mtaStsTxtName }} + → {{ $this->mtaStsTxtValue }} +
+
+
+ + {{-- Zertifikate --}} +
+
+ + Zertifikate +
+ +
    + @foreach($hosts as $h) + @php $badge = $this->statusBadge($h); @endphp +
  • +
    + + {{ $h['host'] }} +
    + +
    + {{ $badge['text'] }} +
    + + + +
    +
    +
  • + @endforeach +
+
+
diff --git a/resources/views/livewire/ui/system/general-form.blade.php b/resources/views/livewire/ui/system/general-form.blade.php new file mode 100644 index 0000000..a6a5c96 --- /dev/null +++ b/resources/views/livewire/ui/system/general-form.blade.php @@ -0,0 +1,48 @@ +{{-- resources/views/livewire/system/settings/general-form.blade.php --}} +
+
+
+ +
+ {{ $instance_name }} +
+

Wird bei der Installation festgelegt (read-only).

+
+ +
+ + + @error('locale')

{{ $message }}

@enderror +
+ +
+ + + @error('timezone')

{{ $message }}

@enderror +
+ +
+ + + @error('session_timeout')

{{ $message }}

@enderror +
+
+ +
+ +
+
diff --git a/resources/views/livewire/ui/system/security-form.blade.php b/resources/views/livewire/ui/system/security-form.blade.php new file mode 100644 index 0000000..04b21c8 --- /dev/null +++ b/resources/views/livewire/ui/system/security-form.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Be like water. --}} +
diff --git a/resources/views/livewire/ui/system/settings-form.blade.php b/resources/views/livewire/ui/system/settings-form.blade.php new file mode 100644 index 0000000..4bc922b --- /dev/null +++ b/resources/views/livewire/ui/system/settings-form.blade.php @@ -0,0 +1,106 @@ +{{-- resources/views/livewire/ui/system/settings-form.blade.php --}} +
+ {{-- Tabs --}} +
+ + + +
+ + {{-- Allgemein --}} +
+
+
Instanzname
+
{{ $instance_name }}
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {{-- Domains & SSL --}} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+
SSL automatisch erstellen (Let's Encrypt)
+
Zertifikate werden automatisch angelegt und verlängert.
+
+ +
+
+
+ + {{-- Sicherheit --}} +
+
+
+
+
Zwei-Faktor-Authentifizierung
+
TOTP/WebAuthn aktivieren.
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+ + {{-- Footer: Aktionen --}} +
+ +
+
diff --git a/resources/views/pages/dashboard.blade.php b/resources/views/pages/dashboard.blade.php new file mode 100644 index 0000000..2a9b337 --- /dev/null +++ b/resources/views/pages/dashboard.blade.php @@ -0,0 +1,9 @@ +@extends('layouts.app') + +@section('title', 'Wizard') + +@section('content') +
+ Dashboard +
+@endsection diff --git a/resources/views/ui/dashboard/index.blade.php b/resources/views/ui/dashboard/index.blade.php new file mode 100644 index 0000000..6f4cdc8 --- /dev/null +++ b/resources/views/ui/dashboard/index.blade.php @@ -0,0 +1,34 @@ +{{-- resources/views/ui/dashboard/index.blade.php --}} +@extends('layouts.app') + +@section('title', 'Dashboard') +@section('header_title', 'Dashboard') + +@section('content') +
+
+ +
+ +
+ +
+ + {{--
--}} +{{-- @livewire('ui.dashboard.services-health') --}}{{----}}{{-- NEU --}} +{{-- @livewire('ui.dashboard.mail-kpis-card', ['key'=>'outgoing_queue'])--}} +{{-- @livewire('ui.dashboard.app-updates') --}}{{-- NEU --}} +{{--
--}} + +{{--
--}} +{{-- @livewire('ui.dashboard.mail-trend-chart')--}} +{{-- @livewire('ui.dashboard.alerts-feed') --}}{{-- NEU --}} +{{--
--}} + +
+ @livewire('ui.dashboard.domains-panel') + @livewire('ui.dashboard.recent-logins-table') +
+ +
+@endsection diff --git a/resources/views/ui/domain/index.blade.php b/resources/views/ui/domain/index.blade.php new file mode 100644 index 0000000..3e5766b --- /dev/null +++ b/resources/views/ui/domain/index.blade.php @@ -0,0 +1,71 @@ +{{-- resources/views/domains/index.blade.php --}} +@extends('layouts.app') + +@section('content') +
+

Domains

+ +
+@endsection + +{{--@extends('layouts.app')--}} + +{{--@section('content')--}} +{{--
--}} +{{--

Domains

--}} + +{{--
--}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- @foreach(\App\Models\Domain::orderBy('domain')->get() as $d)--}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- @endforeach--}} +{{-- --}} +{{--
DomainAktivTyp
{{ $d->domain }}--}} +{{-- --}} +{{-- {{ $d->is_active ? 'aktiv' : 'inaktiv' }}--}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- {{ $d->is_system ? 'System' : 'Kunde' }}--}} +{{-- --}} +{{-- --}} +{{-- --}} +{{-- DNS-Assistent--}} +{{-- --}} +{{--
--}} +{{--
--}} +{{--
--}} + +{{-- --}} +{{--@endsection--}} diff --git a/resources/views/ui/security/alt/auth-2fa.blade.php b/resources/views/ui/security/alt/auth-2fa.blade.php new file mode 100644 index 0000000..2e3309c --- /dev/null +++ b/resources/views/ui/security/alt/auth-2fa.blade.php @@ -0,0 +1,4 @@ +@extends('layouts.app') +@section('content') + @livewire('ui.security.auth2fa-form') +@endsection diff --git a/resources/views/ui/security/audit-logs.blade.php b/resources/views/ui/security/audit-logs.blade.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/resources/views/ui/security/audit-logs.blade.php @@ -0,0 +1 @@ + 'Account Security', 'href' => route('ui.security.index')], + ['label' => 'SSL', 'href' => route('ui.security.ssl')], + ['label' => 'Fail2Ban', 'href' => route('ui.security.fail2ban')], + ['label' => 'Spam / Rspamd', 'href' => route('ui.security.rspamd')], + ['label' => 'TLS & Ciphers', 'href' => route('ui.security.tls')], + ['label' => 'Audit-Logs', 'href' => route('ui.security.audit')], +]" /> + +
+

Security

+

Richtlinien und Kontoschutz.

+
+ + + + {{--
--}} +{{--
--}} +{{--

Sicherheit

--}} +{{--

Richtlinien und technische Schutzmaßnahmen.

--}} +{{--
--}} + +{{--
--}} +{{-- --}}{{-- 2FA als kompakte Karte --}} +{{-- @livewire('ui.security.auth2fa-form')--}} + +{{-- --}}{{-- Platzhalter-Karten (klein) – füllst du später aus --}} +{{--
--}} +{{--
--}} +{{-- --}} +{{-- TLS & Ciphers--}} +{{--
--}} +{{--

TLS-Versionen & Cipher-Suites konfigurieren.

--}} +{{--
--}} + +{{--
--}} +{{--
--}} +{{-- --}} +{{-- Ratelimits--}} +{{--
--}} +{{--

Login-Versuche & API-Calls begrenzen.

--}} +{{--
--}} + +{{--
--}} +{{--
--}} +{{-- --}} +{{-- Audit-Logs--}} +{{--
--}} +{{--

Sicherheitsrelevante Ereignisse einsehen.

--}} +{{--
--}} +{{--
--}} +{{--
--}} + +@endsection diff --git a/resources/views/ui/security/rspamd.blade.php b/resources/views/ui/security/rspamd.blade.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/resources/views/ui/security/rspamd.blade.php @@ -0,0 +1 @@ +--}} +{{-- --}} +{{--
--}} +{{--@endsection--}} +{{-- resources/views/ui/system/settings/index.blade.php --}} +@extends('layouts.app') + +@section('title', 'System · Einstellungen') +@section('header_title', 'System · Einstellungen') + +@section('content') +
+ +
+

Allgemeine Einstellungen

+

+ Instanzname, Sprache und Zeitkonfiguration für die gesamte Installation. +

+
+ {{-- Top-Navigation im Chip-Stil (wie die Dashboard-KPIs) --}} + + + {{-- Abschnitt: Allgemein --}} +
+
+ {{-- Card-Header im gleichen „Badge“-Stil wie auf dem Dashboard --}} +
+
+ + Allgemein +
+
+ + {{-- Livewire-Form (Allgemein) --}} + +
+
+ + + {{-- Platzhalter für weitere Tabs (später eigene Livewire-Komponenten) --}} +
+
+
+

Domains & SSL

+

+ Verwaltung der System-Domain, Subdomains und TLS-Zertifikate. +

+
+ +
+
+ + Domains & SSL +
+
+
+ +
+
+
+
+@endsection diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php new file mode 100644 index 0000000..4252cf2 --- /dev/null +++ b/resources/views/vendor/wire-elements-modal/modal.blade.php @@ -0,0 +1,105 @@ +{{-- resources/views/vendor/livewire-ui-modal/modal.blade.php --}} +
+ @isset($jsPath)@endisset + @isset($cssPath)@endisset + + +
+ +{{--
--}} +{{-- @isset($jsPath)--}} +{{-- --}} +{{-- @endisset--}} +{{-- @isset($cssPath)--}} +{{-- --}} +{{-- @endisset--}} + +{{--
--}} +{{--
--}} +{{--
--}} +{{--
--}} diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..55274c1 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,27 @@ +group(function () { +// Route::get('/tasks/active', [\App\Http\Controllers\TaskFeedController::class, 'active']); +// Route::post('/tasks/ack', [\App\Http\Controllers\TaskFeedController::class, 'ack']); +//}); +//Route::middleware('auth:sanctum')->get('tasks/active', [TaskStatusController::class, 'active']); + +//Route::get('tasks/active', [TaskStatusController::class, 'active']); +//Route::middleware('auth:sanctum')->group(function () { + +// Route::get('/tasks/active', function (Request $r) { +//// return response()->json([ +//// 'items' => ToastBus::listForUser($r->user()->id), +//// ]); +//// }); +// +// Route::post('/tasks/{taskId}/ack', function (Request $r, string $taskId) { +// ToastBus::ack($r->user()->id, $taskId); +// return response()->noContent(); +// }); +//}); diff --git a/routes/channels.php b/routes/channels.php index df2ad28..cdc0565 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -2,6 +2,8 @@ use Illuminate\Support\Facades\Broadcast; -Broadcast::channel('App.Models.User.{id}', function ($user, $id) { - return (int) $user->id === (int) $id; -}); +//Broadcast::channel('App.Models.User.{id}', function ($user, $id) { +// return (int) $user->id === (int) $id; +//}); + +Broadcast::channel('system.tasks', fn() => true); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..5d24d0c 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,3 +6,5 @@ use Illuminate\Support\Facades\Artisan; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +\Illuminate\Support\Facades\Schedule::job(\App\Jobs\RunHealthChecks::class)->everytenSeconds()->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index a523754..fbad7e1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,20 +1,88 @@ name('login'); + +Route::get('/dashboard', [\App\Http\Controllers\UI\DashboardController::class, 'index']) + ->middleware(['auth']) // falls gewünscht + ->name('ui.dashboard'); + +//Route::middleware(['auth']) // falls du auth nutzt +//->get('/system/settings', [SettingsController::class, 'index']) +// ->name('ui.system.settings'); + +Route::middleware(['auth']) + ->prefix('system') + ->name('ui.system.') + ->group(function () { + Route::get('/settings', [\App\Http\Controllers\UI\System\SettingsController::class, 'index']) + ->name('settings'); + }); + +Route::middleware(['auth']) + ->prefix('security') + ->name('ui.security.') + ->group(function () { + Route::get('/', [SecurityController::class, 'index'])->name('index'); + Route::get('/ssl', [SecurityController::class, 'ssl'])->name('ssl'); + Route::get('/fail2ban', [SecurityController::class, 'fail2ban'])->name('fail2ban'); + Route::get('/rspamd', [SecurityController::class, 'rspamd'])->name('rspamd'); + Route::get('/tls-ciphers', [SecurityController::class, 'tlsCiphers'])->name('tls'); + Route::get('/audit-logs', [SecurityController::class, 'auditLogs'])->name('audit'); +}); + +Route::middleware(['auth']) + ->name('ui.domain.') + ->group(function () { + Route::get('/domains', [DomainDnsController::class, 'index'])->name('index'); +// Route::get('/api/domains/{domain}/dns', [DomainDnsController::class, 'show']) +// ->name('dns'); // JSON für das Modal +}); + +//Route::middleware(['auth']) +// ->get('/security/recovery-codes/download', [RecoveryCodeDownloadController::class, 'download']) +// ->name('security.recovery.download') +// ->middleware('signed'); // wichtig: signierte URL + + +Route::middleware(['web','auth']) // nutzt Session, kein Token nötig +->get('/ui/tasks/active', [TaskFeedController::class, 'active']) + ->name('ui.tasks.active'); + +//Route::middleware(['auth'])->group(function () { +// Route::get('/tasks/active', [TaskFeedController::class, 'active']) +// ->name('tasks.active'); +// Route::post('/tasks/ack', [TaskFeedController::class, 'ack']) +// ->name('tasks.ack'); +//}); +//Route::middleware(['web','auth']) // gleiche Session wie im Dashboard +//->get('/ui/tasks/active', [\App\Http\Controllers\Api\TaskStatusController::class, 'index']) +// ->name('ui.tasks.active'); + +//Route::get('/dashboard', [DashboardController::class, 'show'])->name('dashboard'); +Route::get('/login', [LoginController::class, 'show'])->name('login'); +Route::get('/signup', [\App\Http\Controllers\Auth\SignUpController::class, 'show' ])->middleware('signup.open')->name('signup'); +Route::post('/logout', [\App\Http\Controllers\Auth\LoginController::class, 'logout'])->name('logout'); Route::middleware(['auth', 'ensure.setup'])->group(function () { // Route::get('/dashboard', Dashboard::class)->name('dashboard'); Route::get('/setup', [SetupWizard::class, 'show'])->name('setup.wizard'); }); + + //Route::middleware(['auth', 'ensure.setup'])->group(function () { // Route::get('/dashboard', fn() => view('dashboard'))->name('dashboard'); //});