diff --git a/app/Livewire/Certs/Index.php b/app/Livewire/Certs/Index.php index 5527778..f24718a 100644 --- a/app/Livewire/Certs/Index.php +++ b/app/Livewire/Certs/Index.php @@ -79,7 +79,7 @@ class Index extends Component $this->ready = true; } - public function addEndpoint(): void + public function addEndpoint(CertService $certs): void { $this->gate(); @@ -90,8 +90,19 @@ class Index extends Component 'port' => ['required', 'integer', 'min:1', 'max:65535'], ]); - CertEndpoint::create($data); + $ep = CertEndpoint::create($data); $this->audit('cert.endpoint_add', $data['host'].':'.$data['port']); + + // Probe the new endpoint right away so its validity shows immediately (not only after a + // manual refresh / the next scheduled scan). + try { + $res = $certs->check($ep->host, $ep->port); + $res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error'; + $this->checks[$ep->uuid] = $res; + } catch (Throwable $e) { + $this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()]; + } + $this->reset('label', 'host'); $this->port = 443; $this->dispatch('notify', message: __('certs.added', ['host' => $data['host']])); diff --git a/app/Livewire/Health/Index.php b/app/Livewire/Health/Index.php index 7673956..5d4d342 100644 --- a/app/Livewire/Health/Index.php +++ b/app/Livewire/Health/Index.php @@ -79,7 +79,7 @@ class Index extends Component $this->ready = true; } - public function addCheck(): void + public function addCheck(HealthService $health): void { $this->gate(); @@ -98,7 +98,7 @@ class Index extends Component $data = $this->validate($rules); - HealthCheck::create([ + $check = HealthCheck::create([ 'label' => $data['label'] ?? null, 'type' => $data['type'], 'target' => $data['target'], @@ -106,6 +106,15 @@ class Index extends Component ]); $this->audit('health.check_add', $data['target']); + + // Probe the new check right away so its status/latency shows immediately (not only after a + // manual refresh / the next scheduled scan). + try { + $this->results[$check->uuid] = $health->probe($check); + } catch (Throwable $e) { + $this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()]; + } + $this->reset('label', 'target', 'port'); $this->type = 'http'; $this->dispatch('notify', message: __('health.added')); diff --git a/app/Livewire/Settings/Email.php b/app/Livewire/Settings/Email.php index 92c2f7d..836f863 100644 --- a/app/Livewire/Settings/Email.php +++ b/app/Livewire/Settings/Email.php @@ -99,6 +99,38 @@ class Email extends Component $this->dispatch('notify', message: __('mail.notify_saved')); } + /** + * Fully clear the SMTP configuration — removes every mail_* setting (incl. the encrypted + * password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure. + */ + public function clearConfig(): void + { + abort_unless(auth()->user()?->can('manage-panel'), 403); + + foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) { + Setting::forget($key); + } + + $this->mail_host = ''; + $this->mail_port = 587; + $this->mail_username = ''; + $this->mail_password = ''; + $this->mail_encryption = 'tls'; + $this->mail_from_address = ''; + $this->mail_from_name = ''; + $this->passwordSet = false; + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()->name, + 'action' => 'mail.reset', + 'target' => '—', + 'ip' => request()->ip(), + ]); + + $this->dispatch('notify', message: __('mail.notify_reset')); + } + /** * Send a one-line test mail to the CURRENT user's own address, using a mailer built * from the saved settings. Never targets anyone else; never leaks the password — any diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 363c259..93f505b 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -31,4 +31,11 @@ class Setting extends Model static::query()->updateOrCreate(['key' => $key], ['value' => $value]); Cache::forget("setting:{$key}"); } + + /** Remove a setting entirely (so get() falls back to its default). */ + public static function forget(string $key): void + { + static::query()->whereKey($key)->delete(); + Cache::forget("setting:{$key}"); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 189d00a..eebe7e2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Vite; @@ -89,6 +90,13 @@ class AppServiceProvider extends ServiceProvider } $this->applyMailSettings(); + + // The queue worker is a LONG-LIVED process: boot() runs once at startup, so a mail-config + // change (or SMTP first configured after the worker started — the normal case) would never + // reach queued mail, and a fired alert's e-mail would silently use the stale/log mailer. + // Re-apply the DB SMTP settings before every job so queued alert/reset mail always sends + // with the current config. + Queue::before(fn () => $this->applyMailSettings()); } /** diff --git a/lang/de/mail.php b/lang/de/mail.php index 8d77a4b..aeb22e0 100644 --- a/lang/de/mail.php +++ b/lang/de/mail.php @@ -26,6 +26,7 @@ return [ // Aktionen 'save' => 'Speichern', 'test_send' => 'Testmail senden', + 'reset' => 'Zurücksetzen', // Testmail-Inhalt 'test_subject' => 'Clusev — Testmail', @@ -33,6 +34,7 @@ return [ // Benachrichtigungen 'notify_saved' => 'SMTP-Einstellungen gespeichert.', + 'notify_reset' => 'SMTP-Konfiguration zurückgesetzt.', 'notify_test_ok' => 'Testmail an :email gesendet.', 'notify_test_failed' => 'Testmail fehlgeschlagen: :error', 'notify_test_throttled' => 'Zu viele Testmails – bitte in :seconds Sekunden erneut.', diff --git a/lang/en/mail.php b/lang/en/mail.php index fefec1a..25c9dbf 100644 --- a/lang/en/mail.php +++ b/lang/en/mail.php @@ -26,6 +26,7 @@ return [ // Actions 'save' => 'Save', 'test_send' => 'Send test mail', + 'reset' => 'Reset', // Test mail content 'test_subject' => 'Clusev — test mail', @@ -33,6 +34,7 @@ return [ // Notifications 'notify_saved' => 'SMTP settings saved.', + 'notify_reset' => 'SMTP configuration reset.', 'notify_test_ok' => 'Test mail sent to :email.', 'notify_test_failed' => 'Test mail failed: :error', 'notify_test_throttled' => 'Too many test e-mails — try again in :seconds seconds.', diff --git a/resources/js/app.js b/resources/js/app.js index e0b5a94..3ca10a3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -527,7 +527,10 @@ document.addEventListener('alpine:init', () => { // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS. // `h` (Help) is intentionally absent — it resolves via the config-driven nav list in go() // so it can point at the external docs URL instead of an in-app route. - const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', t: '/terminal' }; + // NOTE: 't' (Terminal) is deliberately NOT here — /terminal is admin-only (manage-fleet), so it + // is a ROLE-FILTERED entry in the server-rendered nav list instead; go() resolves it there only + // when the user actually has it, so lower roles never dead-end on a 403. + const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' }; window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({ nav, diff --git a/resources/views/components/command-palette.blade.php b/resources/views/components/command-palette.blade.php index 57150fe..e2efdbc 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -31,6 +31,13 @@ ['label' => __('shell.nav_help'), 'href' => config('clusev.docs_url'), 'hint' => 'g h'], ]; + // Terminal is admin-only (manage-fleet) — add its "g t" chord + palette entry ONLY for admins, + // so lower roles neither see it nor dead-end on the now-gated /terminal route. + if (auth()->user()?->can('manage-fleet')) { + $chords[] = ['g t', __('shell.nav_terminal')]; + $nav[] = ['label' => __('shell.nav_terminal'), 'href' => '/terminal', 'hint' => 'g t']; + } + $actions = [ ['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''], ]; diff --git a/resources/views/components/status-pill.blade.php b/resources/views/components/status-pill.blade.php index b861fec..407daaf 100644 --- a/resources/views/components/status-pill.blade.php +++ b/resources/views/components/status-pill.blade.php @@ -7,7 +7,8 @@ 'pending' => 'text-cyan border-cyan/20 bg-cyan/10', ][$status] ?? 'text-ink-3 border-line bg-line'; @endphp -merge(['class' => "inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-xs $c"]) }}> +{{-- h-8 = the small-button height, so a status pill and the action buttons in the same row line up. --}} +merge(['class' => "inline-flex h-8 items-center gap-1.5 rounded-sm border px-2.5 font-mono text-xs $c"]) }}> {{ $slot }} diff --git a/resources/views/livewire/servers/server-terminal.blade.php b/resources/views/livewire/servers/server-terminal.blade.php index eb481b2..fc1706d 100644 --- a/resources/views/livewire/servers/server-terminal.blade.php +++ b/resources/views/livewire/servers/server-terminal.blade.php @@ -10,7 +10,10 @@ - + {{-- Server name via a data-attr, NEVER interpolated into the Alpine expression: Blade + escaping does not protect an x-text JS sink (the browser entity-decodes before + Alpine evals), so a name like x'+alert(1)+' would execute. dataset = plain string. --}} + diff --git a/resources/views/livewire/settings/email.blade.php b/resources/views/livewire/settings/email.blade.php index d863572..8d78dfe 100644 --- a/resources/views/livewire/settings/email.blade.php +++ b/resources/views/livewire/settings/email.blade.php @@ -60,16 +60,25 @@ -
- - - {{ __('mail.test_send') }} - - - - {{ __('common.save') }} - +
+
+ @if ($this->configured()) + + {{ __('mail.reset') }} + + @endif +
+
+ + + {{ __('mail.test_send') }} + + + + {{ __('common.save') }} + +
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 028da8a..c8b9a3f 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -36,7 +36,7 @@ - + diff --git a/tests/Feature/SmtpConfigTest.php b/tests/Feature/SmtpConfigTest.php index b5abb6a..c8c7041 100644 --- a/tests/Feature/SmtpConfigTest.php +++ b/tests/Feature/SmtpConfigTest.php @@ -56,6 +56,31 @@ class SmtpConfigTest extends TestCase $this->assertDatabaseHas('audit_events', ['action' => 'mail.configure', 'target' => 'smtp.example.com']); } + public function test_clear_config_removes_all_mail_settings(): void + { + $this->actingUser(); + Setting::put('mail_host', 'smtp.example.com'); + Setting::put('mail_port', '2525'); + Setting::put('mail_username', 'panel'); + Setting::put('mail_password', Crypt::encryptString('s3cr3t-pw')); + Setting::put('mail_encryption', 'ssl'); + Setting::put('mail_from_address', 'panel@example.com'); + Setting::put('mail_from_name', 'Clusev'); + + Livewire::test(Email::class) + ->assertSet('mail_host', 'smtp.example.com') // mounted from the stored value + ->call('clearConfig') + ->assertHasNoErrors() + ->assertSet('mail_host', '') + ->assertSet('mail_from_address', '') + ->assertSet('passwordSet', false); + + foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) { + $this->assertNull(Setting::get($key), "$key must be cleared"); + } + $this->assertDatabaseHas('audit_events', ['action' => 'mail.reset']); + } + public function test_password_is_not_overwritten_when_left_blank(): void { $this->actingUser();