From 7af7b2fb2b418823915ec8959812dd353ef68172 Mon Sep 17 00:00:00 2001 From: boban Date: Tue, 7 Oct 2025 20:24:26 +0200 Subject: [PATCH] =?UTF-8?q?Hinzuf=C3=BCgen=20der=20Seiten=20Seccrity=20und?= =?UTF-8?q?=20Domains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/GenerateTlsaRecord.php | 95 +++ app/Enums/Role.php | 15 + app/Events/CertPing.php | 20 + app/Events/CertProvisionProgress.php | 42 ++ app/Events/CertStatusUpdated.php | 45 ++ app/Events/TaskUpdated.php | 35 + app/Events/Test.php | 36 ++ app/Helpers/helpers.php | 29 + .../Controllers/Api/TaskFeedController.php | 33 + .../Controllers/Api/TaskStatusController.php | 74 +++ ...PageController.php => LoginController.php} | 2 +- .../Controllers/Auth/SignUpController.php | 15 + .../Controllers/Pages/DashboardController.php | 15 + .../Controllers/UI/DashboardController.php | 16 + .../UI/Domain/DomainDnsController.php | 25 + .../RecoveryCodeDownloadController.php | 42 ++ .../UI/Security/SecurityController.php | 40 ++ .../UI/System/SettingsController.php | 14 + app/Http/Middleware/SignupOpen.php | 25 + app/Jobs/ProvisionCertJob.php | 65 +- app/Jobs/RunHealthChecks.php | 381 +++++++++++ app/Jobs/SimulateCertIssue.php | 82 +++ app/Livewire/Auth/LoginForm.php | 3 +- app/Livewire/Auth/SignupForm.php | 62 ++ app/Livewire/PingButton.php | 85 +++ app/Livewire/Ui/Dashboard/DomainsPanel.php | 69 ++ app/Livewire/Ui/Dashboard/HealthCard.php | 292 +++++++++ .../Ui/Dashboard/RecentLoginsTable.php | 19 + app/Livewire/Ui/Dashboard/TopBar.php | 68 ++ app/Livewire/Ui/Domain/DomainDnsList.php | 34 + .../Ui/Domain/Modal/DomainDnsModal.php | 232 +++++++ .../Ui/Security/AccountSecurityForm.php | 194 ++++++ app/Livewire/Ui/Security/AuditLogsTable.php | 13 + app/Livewire/Ui/Security/Auth2faForm.php | 42 ++ app/Livewire/Ui/Security/Fail2BanForm.php | 13 + .../Ui/Security/Modal/Email2faSetupModal.php | 81 +++ .../Ui/Security/Modal/RecoveryCodesModal.php | 160 +++++ .../Ui/Security/Modal/TotpSetupModal.php | 89 +++ app/Livewire/Ui/Security/RspamdForm.php | 13 + .../Ui/Security/SslCertificatesTable.php | 13 + app/Livewire/Ui/Security/TlsCiphersForm.php | 13 + app/Livewire/Ui/System/DomainsSslForm.php | 294 +++++++++ app/Livewire/Ui/System/GeneralForm.php | 50 ++ app/Livewire/Ui/System/SecurityForm.php | 13 + app/Livewire/Ui/System/SettingsForm.php | 92 +++ app/Models/DkimKey.php | 5 + app/Models/DmarcRecord.php | 22 + app/Models/Domain.php | 28 +- app/Models/MailUser.php | 9 +- app/Models/Setting.php | 8 + app/Models/SpfRecord.php | 15 + app/Models/TlsaRecord.php | 19 + app/Models/TwoFactorMethod.php | 18 + app/Models/TwoFactorRecoveryCode.php | 82 +++ app/Models/User.php | 20 +- app/Services/DnsRecordService.php | 69 ++ app/Support/Hostnames.php | 19 + app/Support/ToastBus.php | 78 +++ app/View/Components/Partials/Header.php | 27 + app/View/Components/Partials/Sidebar.php | 27 + bootstrap/app.php | 2 + composer.json | 5 +- composer.lock | 602 +++++++++++++----- config/app.php | 2 + config/auth.php | 1 - config/broadcasting.php | 45 +- config/menu/sidebar.php | 63 ++ config/reverb.php | 115 +++- config/ui-menu.php | 63 ++ config/ui/header.php | 44 ++ config/wire-elements-modal.php | 52 ++ .../0001_01_01_000000_create_users_table.php | 9 +- ...2025_09_27_153255_create_domains_table.php | 1 + ...5_09_27_153311_create_mail_users_table.php | 16 +- ...25_09_27_153347_create_dkim_keys_table.php | 11 +- ...120743_create_two_factor_methods_table.php | 35 + ...create_two_factor_recovery_codes_table.php | 30 + ..._10_06_091010_create_spf_records_table.php | 32 + ...0_06_091041_create_dmarc_records_table.php | 36 ++ ...10_06_185027_create_tlsa_records_table.php | 38 ++ database/seeders/SystemDomainSeeder.php | 124 ++++ package-lock.json | 9 +- package.json | 3 +- resources/css/app.css | 449 ++++++++++++- .../fonts/BaiJamjuree/bai-jamjuree-200.woff2 | Bin 0 -> 10428 bytes .../BaiJamjuree/bai-jamjuree-200italic.woff2 | Bin 0 -> 11844 bytes .../fonts/BaiJamjuree/bai-jamjuree-300.woff2 | Bin 0 -> 10728 bytes .../BaiJamjuree/bai-jamjuree-300italic.woff2 | Bin 0 -> 12028 bytes .../fonts/BaiJamjuree/bai-jamjuree-500.woff2 | Bin 0 -> 10808 bytes .../BaiJamjuree/bai-jamjuree-500italic.woff2 | Bin 0 -> 12176 bytes .../fonts/BaiJamjuree/bai-jamjuree-600.woff2 | Bin 0 -> 10840 bytes .../BaiJamjuree/bai-jamjuree-600italic.woff2 | Bin 0 -> 12076 bytes .../fonts/BaiJamjuree/bai-jamjuree-700.woff2 | Bin 0 -> 10656 bytes .../BaiJamjuree/bai-jamjuree-700italic.woff2 | Bin 0 -> 11828 bytes .../BaiJamjuree/bai-jamjuree-italic.woff2 | Bin 0 -> 11908 bytes .../BaiJamjuree/bai-jamjuree-regular.woff2 | Bin 0 -> 10632 bytes resources/fonts/BaiJamjuree/font.css | 97 +++ resources/fonts/Space/font.css | 7 + resources/fonts/Space/space age.otf | Bin 0 -> 18496 bytes resources/fonts/Space/space age.ttf | Bin 0 -> 26748 bytes resources/js/app.js | 4 +- resources/js/bootstrap.js | 4 + resources/js/components/sidebar.js | 376 +++++++++++ resources/js/plugins/GlassToastra/style.css | 31 +- .../js/plugins/GlassToastra/toastra.glass.js | 31 +- resources/js/plugins/Toastra/src/message.css | 2 +- resources/js/ui/toast.js | 25 + resources/js/utils/events.js | 80 ++- resources/js/webserver/connection.js | 17 + resources/js/webserver/events.js | 24 + resources/js/webserver/websocket.js | 431 ++++++++++++- resources/views/auth/login.blade.php | 69 +- resources/views/auth/signup.blade.php | 27 + .../views/components/chip-toggle.blade.php | 19 + .../icons/icon-logo-circle.blade.php | 17 + .../components/icons/icon-logo.blade.php | 13 + .../views/components/page/pills.blade.php | 16 + .../components/partials/header.blade.php | 97 +++ .../components/partials/sidebar.blade.php | 249 ++++++++ resources/views/layouts/app.blade.php | 144 ++++- .../views/livewire/auth/login-form.blade.php | 138 ++-- .../views/livewire/auth/signup-form.blade.php | 124 ++++ .../views/livewire/ping-button.blade.php | 10 + .../views/livewire/setup/wizard.blade.php | 1 + .../ui/dashboard/domains-panel.blade.php | 35 + .../ui/dashboard/health-card.blade.php | 210 ++++++ .../dashboard/recent-logins-table.blade.php | 37 ++ .../livewire/ui/dashboard/top-bar.blade.php | 50 ++ .../ui/domain/domain-dns-list.blade.php | 47 ++ .../domain/modal/domain-dns-modal.blade.php | 102 +++ .../security/account-security-form.blade.php | 233 +++++++ .../ui/security/audit-logs-table.blade.php | 3 + .../ui/security/fail2-ban-form.blade.php | 3 + .../modal/email2fa-setup-modal.blade.php | 51 ++ .../modal/recovery-codes-modal.blade.php | 82 +++ .../security/modal/totp-setup-modal.blade.php | 94 +++ .../ui/security/rspamd-form.blade.php | 3 + .../security/ssl-certificates-table.blade.php | 3 + .../ui/security/tls-ciphers-form.blade.php | 3 + .../ui/system/domains-ssl-form.blade.php | 255 ++++++++ .../livewire/ui/system/general-form.blade.php | 48 ++ .../ui/system/security-form.blade.php | 3 + .../ui/system/settings-form.blade.php | 106 +++ resources/views/pages/dashboard.blade.php | 9 + resources/views/ui/dashboard/index.blade.php | 34 + resources/views/ui/domain/index.blade.php | 71 +++ .../views/ui/security/alt/auth-2fa.blade.php | 4 + .../views/ui/security/audit-logs.blade.php | 1 + .../views/ui/security/fail2ban.blade.php | 1 + resources/views/ui/security/index.blade.php | 61 ++ resources/views/ui/security/rspamd.blade.php | 1 + resources/views/ui/security/ssl.blade.php | 1 + .../views/ui/security/tls-ciphers.blade.php | 1 + resources/views/ui/system/settings.blade.php | 85 +++ .../wire-elements-modal/modal.blade.php | 105 +++ routes/api.php | 27 + routes/channels.php | 8 +- routes/console.php | 2 + routes/web.php | 72 ++- 159 files changed, 8843 insertions(+), 384 deletions(-) create mode 100644 app/Console/Commands/GenerateTlsaRecord.php create mode 100644 app/Enums/Role.php create mode 100644 app/Events/CertPing.php create mode 100644 app/Events/CertProvisionProgress.php create mode 100644 app/Events/CertStatusUpdated.php create mode 100644 app/Events/TaskUpdated.php create mode 100644 app/Events/Test.php create mode 100644 app/Http/Controllers/Api/TaskFeedController.php create mode 100644 app/Http/Controllers/Api/TaskStatusController.php rename app/Http/Controllers/Auth/{LoginPageController.php => LoginController.php} (83%) create mode 100644 app/Http/Controllers/Auth/SignUpController.php create mode 100644 app/Http/Controllers/Pages/DashboardController.php create mode 100644 app/Http/Controllers/UI/DashboardController.php create mode 100644 app/Http/Controllers/UI/Domain/DomainDnsController.php create mode 100644 app/Http/Controllers/UI/Security/RecoveryCodeDownloadController.php create mode 100644 app/Http/Controllers/UI/Security/SecurityController.php create mode 100644 app/Http/Controllers/UI/System/SettingsController.php create mode 100644 app/Http/Middleware/SignupOpen.php create mode 100644 app/Jobs/RunHealthChecks.php create mode 100644 app/Jobs/SimulateCertIssue.php create mode 100644 app/Livewire/Auth/SignupForm.php create mode 100644 app/Livewire/PingButton.php create mode 100644 app/Livewire/Ui/Dashboard/DomainsPanel.php create mode 100644 app/Livewire/Ui/Dashboard/HealthCard.php create mode 100644 app/Livewire/Ui/Dashboard/RecentLoginsTable.php create mode 100644 app/Livewire/Ui/Dashboard/TopBar.php create mode 100644 app/Livewire/Ui/Domain/DomainDnsList.php create mode 100644 app/Livewire/Ui/Domain/Modal/DomainDnsModal.php create mode 100644 app/Livewire/Ui/Security/AccountSecurityForm.php create mode 100644 app/Livewire/Ui/Security/AuditLogsTable.php create mode 100644 app/Livewire/Ui/Security/Auth2faForm.php create mode 100644 app/Livewire/Ui/Security/Fail2BanForm.php create mode 100644 app/Livewire/Ui/Security/Modal/Email2faSetupModal.php create mode 100644 app/Livewire/Ui/Security/Modal/RecoveryCodesModal.php create mode 100644 app/Livewire/Ui/Security/Modal/TotpSetupModal.php create mode 100644 app/Livewire/Ui/Security/RspamdForm.php create mode 100644 app/Livewire/Ui/Security/SslCertificatesTable.php create mode 100644 app/Livewire/Ui/Security/TlsCiphersForm.php create mode 100644 app/Livewire/Ui/System/DomainsSslForm.php create mode 100644 app/Livewire/Ui/System/GeneralForm.php create mode 100644 app/Livewire/Ui/System/SecurityForm.php create mode 100644 app/Livewire/Ui/System/SettingsForm.php create mode 100644 app/Models/DmarcRecord.php create mode 100644 app/Models/SpfRecord.php create mode 100644 app/Models/TlsaRecord.php create mode 100644 app/Models/TwoFactorMethod.php create mode 100644 app/Models/TwoFactorRecoveryCode.php create mode 100644 app/Services/DnsRecordService.php create mode 100644 app/Support/Hostnames.php create mode 100644 app/Support/ToastBus.php create mode 100644 app/View/Components/Partials/Header.php create mode 100644 app/View/Components/Partials/Sidebar.php create mode 100644 config/menu/sidebar.php create mode 100644 config/ui-menu.php create mode 100644 config/ui/header.php create mode 100644 config/wire-elements-modal.php create mode 100644 database/migrations/2025_10_05_120743_create_two_factor_methods_table.php create mode 100644 database/migrations/2025_10_05_180725_create_two_factor_recovery_codes_table.php create mode 100644 database/migrations/2025_10_06_091010_create_spf_records_table.php create mode 100644 database/migrations/2025_10_06_091041_create_dmarc_records_table.php create mode 100644 database/migrations/2025_10_06_185027_create_tlsa_records_table.php create mode 100644 database/seeders/SystemDomainSeeder.php create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-200.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-200italic.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-300italic.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-500italic.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-700italic.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2 create mode 100644 resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2 create mode 100644 resources/fonts/BaiJamjuree/font.css create mode 100644 resources/fonts/Space/font.css create mode 100644 resources/fonts/Space/space age.otf create mode 100644 resources/fonts/Space/space age.ttf create mode 100644 resources/js/components/sidebar.js create mode 100644 resources/js/ui/toast.js create mode 100644 resources/js/webserver/connection.js create mode 100644 resources/js/webserver/events.js create mode 100644 resources/views/auth/signup.blade.php create mode 100644 resources/views/components/chip-toggle.blade.php create mode 100644 resources/views/components/icons/icon-logo-circle.blade.php create mode 100644 resources/views/components/icons/icon-logo.blade.php create mode 100644 resources/views/components/page/pills.blade.php create mode 100644 resources/views/components/partials/header.blade.php create mode 100644 resources/views/components/partials/sidebar.blade.php create mode 100644 resources/views/livewire/auth/signup-form.blade.php create mode 100644 resources/views/livewire/ping-button.blade.php create mode 100644 resources/views/livewire/ui/dashboard/domains-panel.blade.php create mode 100644 resources/views/livewire/ui/dashboard/health-card.blade.php create mode 100644 resources/views/livewire/ui/dashboard/recent-logins-table.blade.php create mode 100644 resources/views/livewire/ui/dashboard/top-bar.blade.php create mode 100644 resources/views/livewire/ui/domain/domain-dns-list.blade.php create mode 100644 resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php create mode 100644 resources/views/livewire/ui/security/account-security-form.blade.php create mode 100644 resources/views/livewire/ui/security/audit-logs-table.blade.php create mode 100644 resources/views/livewire/ui/security/fail2-ban-form.blade.php create mode 100644 resources/views/livewire/ui/security/modal/email2fa-setup-modal.blade.php create mode 100644 resources/views/livewire/ui/security/modal/recovery-codes-modal.blade.php create mode 100644 resources/views/livewire/ui/security/modal/totp-setup-modal.blade.php create mode 100644 resources/views/livewire/ui/security/rspamd-form.blade.php create mode 100644 resources/views/livewire/ui/security/ssl-certificates-table.blade.php create mode 100644 resources/views/livewire/ui/security/tls-ciphers-form.blade.php create mode 100644 resources/views/livewire/ui/system/domains-ssl-form.blade.php create mode 100644 resources/views/livewire/ui/system/general-form.blade.php create mode 100644 resources/views/livewire/ui/system/security-form.blade.php create mode 100644 resources/views/livewire/ui/system/settings-form.blade.php create mode 100644 resources/views/pages/dashboard.blade.php create mode 100644 resources/views/ui/dashboard/index.blade.php create mode 100644 resources/views/ui/domain/index.blade.php create mode 100644 resources/views/ui/security/alt/auth-2fa.blade.php create mode 100644 resources/views/ui/security/audit-logs.blade.php create mode 100644 resources/views/ui/security/fail2ban.blade.php create mode 100644 resources/views/ui/security/index.blade.php create mode 100644 resources/views/ui/security/rspamd.blade.php create mode 100644 resources/views/ui/security/ssl.blade.php create mode 100644 resources/views/ui/security/tls-ciphers.blade.php create mode 100644 resources/views/ui/system/settings.blade.php create mode 100644 resources/views/vendor/wire-elements-modal/modal.blade.php create mode 100644 routes/api.php 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 0000000000000000000000000000000000000000..25553f958caadb920c7a0dd06f5acfcff9e9fce6 GIT binary patch literal 10428 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ν=9tfXyz@4%YDc3EAerIctOyOgY6->hXDRr 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 y=$#ihnrK!CMXTeziP*<%<>uB_2O$KcM literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d3db0599ee91d8f188dd9870cae07a21b770a42a GIT binary patch literal 10728 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 aThX(-UQ*`gLga547>ef>wW7oU0000uVmw^{ literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..dfc55e1e5f6d10f99cff50f4e02f17725b74e327 GIT binary patch literal 10808 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

AjPDjUhZ$%0IE|NB893h&2(^N4*I?a>Cps5 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 Sqy${|ac#7_uYGOJBn1F~jEd6$ literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0c1295a39089ae6538d36d093a8501fb8e22d60c GIT binary patch literal 10840 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 literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c17f7f677d7665476bd1b090e6394824c9625534 GIT binary patch literal 12076 zcmV+{FVoO>Pew8T0RR91052>64gdfE0Bg(u04~!20RR9100000000000000000000 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 W4E_xnC&uIYEUv}cyFzP2`V;{7B-3;N literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e14827bfc9224f1ba95f5fb72c4fac09ba67ceca GIT binary patch literal 10656 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 literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7eb89d4f93bd3babafa88393ab810f38410f37c3 GIT binary patch literal 11908 zcmV-~E_=~;Pew8T0RR9104{_84gdfE0Bh_304^#30RR9100000000000000000000 z0000QSR1W29D`B@U;u>(5eN!_hFIw`3xjq50X7081BYk?AO(aL2Zmr9Sz1L0Vz&c` zs-PV(5+Q6H07b@$QBkKTrx}9(FA4I94c`rYi3~f%jyPB#TW+;FbhM1s<0iDw>>GB_ zBo0$>N?|ezTk!MWa1)Mw$&Hvp-#^KvyrgjM3N7IXO-@lW6A2nCGqZOOSYQQYtI$>g z8C9cO4XiD&5d=Ftzs=q!TSOEo0|h~e7NI}UiWyk5a^co@i%jR$(&g1IYIV7*-d%5Z zJ)&p~bap>%fmAdK1%$#xB@(!n$S49;8QU6F;lcLSY&y@G*Umo|Ff`qK1$xL-z#D=d zTyi7pbh1*1oUi|VTc17Kb9plprvg=wdQscPw_^8kp&$8trxT*G>Md-)Fe=_1aiy5t z&%+i(CTH<>ezVcboc18J5h{EYB?E9sFZ1hV6(90g=`ktY_O|?JbOM+3S>_jhkAhEX?tS_U_k-QM*@MhgkBsl9zPoB0W6Dh6~Oyd zwBs4TK)^w5e?@3&8wx}Up@1Gp2nGiLB8t0Iyv+oRsJdU?yS8yX&_N43mYfif+GBHCdUObtF3xkIjlV8~I2ryA}ZJ4Unz^GL(MS~<}F2WQQ zn5he==*Db4p;)~J;S6DtVT?BtN;7I4&ctCU5U}=PghLqN7)F^6MLXj>$R&()C69#I z_OH9#_nkrrCaoE-!k1y$3{bfV3iaiWEGSg<6s z(P9LobtF?S1G`>GjI%OWHYPKE#fC>iq`3TQQV2vcQf~bdFPN(8Eh)+o#?E3hL6U{; zWSCp5ri1k45S^ZAMKrpOu_8Vl0*$k)yru;qQJZw6`dpI$hl3cus0%$hnewzbASW#8 zVO2Ud@<4>6z}zb%W!PwBvDBhMC=;^6&8wD8bLaT_91kHrZtnUleI-e`JUZoBJ=a(g z7`9Up0VG*ob%?xp4I^7dLH^%%(r3O)TnKWS2A10L<#&7+Su8~{&&m`Li|R*pYO!{? z7lwrwa7`RZrEV$pP29?JSw-vW(m|BDBuYm{K}J4OkWo-j-w+iY+QJAAfdC1Gj)jGU zg^7oShlM8v1{)U^5qse*NXfP^Sr$cXaRioxZ)rr9C)0`u?F-ueWH=PG>EKOPuZMeo`7rin&qPXVF(Ht?{ncq%CHiOE12*%!!;KsK?|QMPMOE z9zR<$WWUK)?vDMi<0n-`7uGj=gn9r~$2fcJr#X7!mi`CK$PJMx z>&TU?SzdUl9l1i%I$9ZQk}V7r8?FqbCab6vDV#mGt(%=LN%C+h5!<-x(+KbFnrJ$; z0n&&~_h5>-Sl{T8ax!v?6r@m_Vx2E6@8w-|G(!l}VPH-QI`*v++=E38`EzMhxPz0 z&!V@RZp@k>&dp}x>OJrrmZzRPRmd2-|f+G;B4t1NNX4zGTOW-5*8 z=|`d3o-uQcKUQ49YS2)qc(2s0&m_c@tw1V;Vgd+8C`3#oBv7q|5C}pLLZD0pAp}Ao zBtjuHq9P1RMOeg=X*Gz9b*5ywnJfQh>!OsZs$T?BuwqlsL?k-Pmy;Ui?2Rb{W8FJzY;al8wO9-P_y^ z^ukI22r)AR5aFEHiTXYp`%F9FpgV54 z?WlWDNXV$@*ivz%$;2lhB9^UyLZKo`DtZP+CdHn1+B4pD!9x~q9$tPCQ896qs#L30 zr$M_;UApz?HDuU`Q4^l@m8ZPzMMvE5s+YXxYae^y6Z2g2jNV7g$(8 zP)LO`3FUn1wP@6&O|zR?4eHZxz?cr>WRkx1jVrFY=#tB3L7;hL7r<|T{0sOB?7IuV zk%s^*ZUg-mKmpKcI<$ZS2ncgf#+W!WhGi0MXRw~TFKgXG?=c`+A|S$xXdlql45+>{ zF~fraf@+Wh#8fi9^voV3EQ{P68W+?fN_RKBncvu8$G9;E3#~1rW!;^qSjWJ$U1NBU zB9h1?A879?3{?{Y$ii9`h7kdUAwz@XIJhpIwS$#t_y)F&=4$Dkk-#X~0}%jq^rq%V z0~Yrh%IBBk2@|cbii;afgGG5#sA>$kV7w{*z0^x8Stlq?B~Sth4hV;5?t8}}05sQF z$&|^DO=gpq_7$MTA{)&Y6sEBdU)&oCf3T`Hd&Ut<^)WjYnKT55$X$F(-2XpYYw45r;5%K<2@7T4VFcA+kHn(GJfqC*Tw;ImpnQzn7RAOoH#qRDHB;^*Rw zVr40Xjq}V>3Qm5ct4~DSb>#_~L&&6fPVYe76N(+vp5#jivcdWQpc$nns4n%%^u1gP zcOo@884;7G_R8JWvvhiU{wC@31r{$BPV4=-tKDcVz$IWos7m*j8ka!0Jl=M#%^MckI#%4Fo{gJ`uOWiKLr)@+JnN=~SEYQ)7WG8bosq z0LAlUoo+Ou9rtOc5FOlHjMg61Kp58nAsjjJHg67an!NQZpeKc}c&DWpiRrm1D#-QF z<5W=VP?OYNiDoS`O6wbd9<-1*DX}z|pgAZ4`SdY@!qLiasnF?SY6_&^e62g>N5DLk znHaJhdOud7WU$m(BwTYPjxFM?x5EGmGhd`t;US#;$zLL3)S);kA<4Km89v>XrMRpn zng~O(?lP7^uknA$1iP>?O28y@8C8$GW9AEn9o2Q-Qc$k%^j}sewGfcgku39^8&SU! zMuAp2n{;9bUsqOP$VLE7)zV|c?{JK9#SDKJ5?knAYrhjMG&wOG9*(B?Z)tfr@e-jhYvy_EX%>OYA-zT7EZUEDB9hwWbFyHiMxuhuq z1KiJ!TZ|GG-FqI-+MX?ZnGGP_77)|csVZ;-=KH=+0_Blr2dfc~k3jFZy zox^s_r=%nCsO84>KXdCXCzQvn+D|m=lI+i&bP45c#^6PkV+?qk`7)d0*|rbd*Y6f1 z-^Lhgz^$ni&8L=zp5#!Y&+^+QhqRQJt%Lf%g~kJ1zD}lz=W}kA1uoeUE~^O^%Psn z%G0ETEDhqtxV`{r#wa0|*}fbVn^=j+2(dxL>_^M1fD<&0FU=jT+(8vO3U;c8Sn5!p zqmw1-Dhqp%Q2PF#9mK3XCr?RDYZzRq3kEadx7AFIVDSgQebqnjjh&53oiOgLc%OVE zP>=EK9NT#>i?q~@!0UBt?6(tvHCSkoA>$^VDp(5A;CX0c;FQ;Miv=0P1*P1(`9VHf457F=!EkM6Kk2Q|>*M(};2}5R1Vo=ksO^;Mms)qlck;zd~2j z>$Y8UZT>Fj1v{twk+7_@#rufMW5=szq zq^rSY56|z)cB_;znUzaBn~GKo-BMW##o$@9s4VL7-ZTf23TUukPPgudc>W1Pq4Tvk z>f7>L%+_WZX$947uXzb=?*l zxc0`E-6NwIo%Qq$m7lp3mSooRfA!QZe!%J%>(AC!k846GspZ$_=jjhWrQ_Q({7kNR zY0(>MZk#e@O>f^#(yaa^8p!xoiS8>W?Q+=y@8P>BAeio`O zoR$i`EJrja>0-wpuJ<=n%g!JpsjMch%>ZP|=9CJZ?>kQlC&Jr5`Qq};4?+k%4^^)+ zqKf(6HfJ{6{Bh^(A@qh|Dw*@i~RMtATE z%&M9Dqs`I6(uwfr2wlF~Mp)zTIA0x?iIc`(j@(qeZkw{xops^aqITqvrvnwti{Eh7+g(iL>Z~#F-M5?3 z;h%?c(eY~96||MS@f4krHkM!RSkZ`z%SJmp?`XAW7hPqu>rRcu-h zp!l37&Col|$hSkD-K(>Ft7Dk=wg+PZsQF+pc`p-|5y4r_7BORelWQ#4XAPpMFl}fp zA%8g;^lLswPNu%)g-3S5U`2WsiBM!Mu+OwgwA8jtBA!LR^CrF4$1zxBxjx{L3l{V9 z(8^V}6qKvG_$aKsmFT1%GJvF=Trxg<>&tlh@|>q*({bX4i@IOJ~b}&`z**=Q-%YROis)%n0aY-d-Mz=N)I_0o>cnvRBO znpI{n*sZ2ce`_H&=mvXceOkk!_RzkGS)Z#LC)n(lG*cgOfQe9Zs-f9c>cT&nk zFRM}=aHyo+*WFHui(glB?4DpT*fR_JN}VREfYC;YbXUdDa-46TPvg{YV6>%UL|;oV z8adz=k6o0U-bRl0RhR$IdED1hoU*9xkpJVGiNbdz*!{F$j6TE0ng-hE#97X5T4lJ{ zS8s-0h0)e}`SxUutXzCqOTHB9Rnlk_#hZI04c~9ez76)w`r(+8?~hc6T_gi6C&_H`extW&&c zqZ!Q1N4=9ia17hp80Tu^ANt}Sn}`OBP226LEF}kM*w_2(lsRYqXj3=%r%X4w+B2sK zayLci&*p%Qdmzk&2dajcTE5t4Awus|`@fpFD#*-qt01~66Qn1fiib9Ff9ICxS6*lg z?UVpX1z?fRoc3?&Vt17VPs>R@K>NLV{PZW*2h|Hte*;FaQHuu0j}UD&9@r`jvBKmS z4Cx#ebXFzer5rz zbZO_gNxqKCFsu*B-ho1;?WT6QA&n{BpBGYj9VgU({HTcQLmzr;Jcz>cHvF>rMXWtX zFyo$>g=NUy?kv8?8xJ|yO7;}3y0bInc9ays-d*e|0VV&Y#Gx(q|By0{A{1Vgfk|v> zz|Ih*t)*3VwuEEexYNW)bEAo(cO@~E%f2&ht(=rcmBxx}CCA*EfK|4F6T5kaK$bbc;R^1#a1zgD3$%)F?Y62^zT-8 z<-gRbuGUDrm7pvO-A|b)(%R%sP0+iGIi#Smos_djFe?8o|sqYgUf$~!MtDJ)|?viA)58PNS&Oig5b)3=`A(o z)gnO4IFk|G`D3Xk;|M%43kS}n|5yv=Jf=)lH>uzzm7J8eF;g@n0O;ga}``% z!=VYj$S~wN&A%A^uqh#@2wzAukqxOUs1-fU@mRPDZP43X8jF@_0?w_UDdc3C zx*(>5Z9CYDqR7ND@ZkB2+3p3x8;jhhHg-(9<%r%{?4IsaQt~SPXu63%MXq!!Ik$_) zak0~%43z8txiF72a8gFeR}}bOwPbHsRluhXn5$)Zj$|=%cr0)DsNu0Zxt+f??2nz7~T)CO9f+f+m*%7yavjYxzyU8juG?->Zws)+H{0>lrF}kCy&=! z{pG-p4O(>+xjP-h9~7(d#;idkHud3O!A21QCEnyQJjbC+7bqhoG1qkEKd@(WVkXf_ zs~$^MN5TzJ7gew{E1zr@XFo-lzc~lX3lgl=utxMy9}E`=EOsAR`Sr67G zCbKc=nt2YDTF$)q{$>=O)yg(-Q6kG+t5Ri9&V@_#R$r<1aC9`aPLW{*vCWT{`N zGzc`UnF)3${w!i}XbqS57n$bu&tU?m8VIx-M2( zCuV(sXMN3HGeD68{Uddw7gJ+-vLKznK;(fv5lb_aa> zT(!4^DoD=Pd7h?qoQ>#W=s_MaRY)eBaJ1G5&IcMHPUBX`x zagq$MoRSaaC#>b=$Ta+f<#wRK#?cpc4KS2x>-Rl zx}XI^OwL%IjeMeqXnTHM+Lfi^=5;C`SdMLvxtM7|Mpu6!bjC$hB+23aii)z*Mt=!a zo}|`P7C_;=I7JX7mrm5zcWhs+ixWO3P-e7;(tLTX^jhe8r|j16@Io# zC1NB;2C5QmUVXDYPISu2g*Vi0XQhwmNGfQB6>>26VzTJu9F5Vl!wN`UEw^s3Yb;<3 zMZ#>RmJLiLOY9|bLxC}yfEJrxUbm{Ir$=;W3k#r#T{%Rw&iCGAvjUs09dPJXhQdQq zYN{n2#U4vBa0z-X5|^@^ZP{#L|F74H-7Eu#7PM?}lqd|~l*V&l+K|FYv0gLL`p!LAr|foJojdzQ6(Yn@;FEC%EtxEm>dJ zUqK-ADv_m0UDlm%#e|bk8$f=S}i>lti9I#aN{s_NKSvpI^ zMc~-kxifRcuN(=%1z6<8XLc;XKMM=En1E#udH5pUSm6H6z~A2mz}QXakKZ}L$R-cP zuS#3eb~jcNDr@Sz%bBktSsh#bdqN<;wc~EuJQdkx*&196;7yxd6yKX4?c|+UZ3t}s z%55={48m(XtU&s3^H_PC@EY0I825{3GGx!p6I`0qy813Z6p`h^OB|YbSx&}`7-SuB zdFTfs%DAkjT{Y17hR0?in7WfBe`_Kjni1u1ns=+kx3V=`M6`;4V6{-Xab8!Fj}ecH zg)>4c>@?AZUDGZQ6aq^oZU%2*`V1pKF*6Bn9J&N%Wj0;niVk%f^~Hc3q6SH;dHv+N zX>x>)#HIilO>a!EKS_zb_w!=!Q~~p>;#>;OES&u|eEn_9I5?6kh^`cRE8O#)yi$hX^obC!elH28 z9xfFiSvy$trmxFNPrVm4w5FrIik(}vqw1+9wa9x)Z#?C5Wiby!2-C3zUrG`hZ;<3l zTmLS>_HOn&lSCVzXsgP8Cql4A($>>k`ugGtu*$RN>eK~RV|0$#ZZ|MMMe%CK29R0VfC(HRb!^@h45MbI@S{;`w2fK*!M z=NbCV<=^X^>i=kBXFis1!g_++UoWgC!Mg|9!-FKavAP=X25Hfax)?6MSAjbj9V%mO z30Et6k+brt-79XmJBkd|#awm9VR9%`ZWL#JIR`pqmxy!*mqSli(CWzH`U>c zr7#suO!2b#Mn*gDuz}>P3Ik^Pvq64Wo%0w+8B)O<3r!QzC90md<{jh2yD7&KUoK0*?%BuX9h^x>fE@2(XO#C9a|1XzkgP&3&cV*=u!cOlNwhQQkUOm=t2t zTK%(l2>Y{z*k&QMNh(t#-0hYtZ)W=3t%?|{H)R5Ru{^n$vbaN?Z^@`LhMrsVoUyph zod5H{*6|TE(S2;6(F~rL{kO;!D|M^xZ+rialx5H}^DXvSs;kH=pRaw1zlCAKZ=dbX z?%4_Rj>B+W3y{dci%abxIJ8U`O80}hXuH&}@yu9h2nJ?H8J!EbOz=K~y;gwigNSac z6)gXtblz4cFn+14-)}4FP4;>Ki6_t1I&Av9*WM-0Oy<^o|sM@HUUGd($*UsV2G7qQV<8QVlgMpb#^!f456M_MHbEjhS=ETud2v$ z9vG(sz!1vsiT%_e6BuHn(&q~3J|4Cz$OsTS*P8RM)g=7_Z^m_8l;ehwhcbkEetXH- z%U6*K!#0qvqUVSN-L-jk3tL?rte5Cbo+Dgi^PZSXq?gTY-bXX? zeMxrNmuF_)C&yhF`~P2n901saJyT$Aa%CIwc%(pK288>!0H|M{N*i}W{+Kg*m!CbE z{?V>&}5(7e~dYva3d5kLnhq;UM z)xz5V$BSF$cTf?xGy}4s4OWBJ=Dy6MfGFMwiv+L?hG7g=L*|+?=V8^F7`*C8bo1sM zFeM892#%vJA7;9jAbIg!b*7;A=Hn@}4#g01dwawRG^F*Oo`y2gMRYDRq28BLbFTGC z5CCiS@apfeo@)Mv!y>-{c>O`c3BC4zoPY4&oB!RVcySg276L$k^?43h-gJQlTKE!A z*z5kC@0q7WHuRsUemFei*fY>A*gRr~ydIzy7?*nv2}>;AnmpR2AKR+t}{ds`si%@;LeH}A4}cmAxFw@th` zq9RYL>oWSZDz6N#A%c2PnvmBzhTK=tZv3{;_y;K?6SH_M!}FQRS;Z$U8)xkSm3{_4 zkDsEMYmsdZO=DQKEU)IZ2Ahheb@@e?OCwk9;#LZ;fNJ+Jy*sx*0j|&A1-cG1GzFn! zRjs0G2=^EwV$MbVeOf_rkA5uzj7Lcl<-C&t>I}?VOt>*2IZoAY?jrboC}ZC6?%d26 zUZo;f=e~+yU1f5Eq*1pH%@SG-(9G1U zhd>dz!d}Wm5Sq6MAGU?P+I8qD-2xIL9-Ufw!RiazR)R|Um%oFPpMZ&JIX1kIy&Zb> z8J8xN)Xo(v6<%LGfw3j>T7#`R1a|OtFKLssuRi1oOGOu~v8{SkvP7x$1 zRHTj4TW(6aZM_*!Q2AkH@LW22cih$P?bXg7duHD&=G37}w@y9g*vG}Km&bQteFhBb z=jG!U@QxuPMhy!J2?qrtqQ=CGn>5#i{pNX6+>c6?Nq9`T3iC}_Xunof;W`c$=5EjBgc!&TIAreG}C=eB*h3=By!wAy@55r1I*hxi4#gpkI zl!HVLc+PTU7z`zdNa{4Ou%diHmxR%b`7#0Hm)@QC59u*^!2TV-)F!&mQ{2qKIq;$$T|ImwMA5%a)m@DX?ytN{-2 zKKKy4hYFPi0;oVs?+~i{>pa-uo(^T9zUWy|Dk?>-XcV1Kzh7aY-JmE9e?_P0nsl8- z8WnV_@x48*Kg~MUPWp+_I(G0Nv8`i7l73RQ@uY|8usLQn;y$~CIsH3~3 KYf?v*IsgFmz^hvT literal 0 HcmV?d00001 diff --git a/resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2 b/resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8d611b5791160a0a77774b6e91a6542e4b0eae3a GIT binary patch literal 10632 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�= zqbn0j5yfDd%Q}*SZRpStW^D#VMneokJ%qID&dhHD6L0YB5idbHMoiea*k0rH<2ke& zevP7f4S|`J<)X5fSzx@*{=m8U3N*@wsI21pY7VW;Hmp=bc*VjRwWHOQnwcBijA~tR&2=F{g$Z}VO}E?@!NeTmOy96N zR(IT0@4oqV+ar>vgY5K76+WEv*Ra3hT}LZ{L8%J!Wcx$;bxufQ1Nj5oH=?~N1obXFhqVTKV!ig&Cc#)CtrMDev zJ%e@5n@WUB?i)h1G8N+VCXL2E``KJ@(Scs)Xsc~Kd0(j9B#qrj{X!{zO-2%G;FYr@YA*Z6h7g3a?W;ZglA2b^-7+N;BNgi_LI9G}RtFgnb}; i`LNc1on-~HtyM3``e!IO8Xx>|j8152so5;ZJO}_6IaL$@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6b22db02f439e426b721c42861e722571c3f755c GIT binary patch literal 18496 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 literal 0 HcmV?d00001 diff --git a/resources/fonts/Space/space age.ttf b/resources/fonts/Space/space age.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e89b0fe64eada100304592d59b4313e9552e9771 GIT binary patch literal 26748 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 { +// 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'); //});