diff --git a/app/Livewire/Help/Index.php b/app/Livewire/Help/Index.php new file mode 100644 index 0000000..04ae037 --- /dev/null +++ b/app/Livewire/Help/Index.php @@ -0,0 +1,55 @@ +.blade.php. + */ +#[Layout('layouts.app')] +class Index extends Component +{ + /** Help topics in display order. */ + private const TOPICS = [ + 'overview', 'domain-tls', 'security', 'updates', + 'servers', 'sessions', 'email', 'audit', 'recovery', + ]; + + public string $topic = 'overview'; + + public function mount(?string $topic = null): void + { + if ($topic !== null) { + $this->topic = $topic; // clamped to a known topic in render() + } + } + + public function render() + { + // Authoritative guard: any unknown topic (deep link, tampered property) → overview, so + // the view never @includes a missing content partial. + if (! in_array($this->topic, self::TOPICS, true)) { + $this->topic = 'overview'; + } + + $labels = [ + 'overview' => __('help.topic_overview'), + 'domain-tls' => __('help.topic_domain_tls'), + 'security' => __('help.topic_security'), + 'updates' => __('help.topic_updates'), + 'servers' => __('help.topic_servers'), + 'sessions' => __('help.topic_sessions'), + 'email' => __('help.topic_email'), + 'audit' => __('help.topic_audit'), + 'recovery' => __('help.topic_recovery'), + ]; + + $topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS); + + return view('livewire.help.index', ['topics' => $topics])->title(__('help.title')); + } +} diff --git a/lang/de/help.php b/lang/de/help.php new file mode 100644 index 0000000..e0c7ba2 --- /dev/null +++ b/lang/de/help.php @@ -0,0 +1,18 @@ + 'Hilfe — Clusev', + 'eyebrow' => 'Hilfe', + 'heading' => 'Hilfe & Dokumentation', + 'subtitle' => 'Einstellungen und Abläufe erklärt.', + + 'topic_overview' => 'Überblick', + 'topic_domain_tls' => 'Domain, TLS & Reverse-Proxy', + 'topic_security' => 'Sicherheit & 2FA', + 'topic_updates' => 'Updates & Versionen', + 'topic_servers' => 'Server & SSH', + 'topic_sessions' => 'Sitzungen & Benutzer', + 'topic_email' => 'E-Mail (SMTP)', + 'topic_audit' => 'Audit-Log', + 'topic_recovery' => 'Konto-Wiederherstellung', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index 5f32f0f..f32255d 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -21,6 +21,7 @@ return [ 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', 'nav_versions' => 'Version', + 'nav_help' => 'Hilfe', // Sidebar — user / 2FA badge 'twofa_on' => '2FA aktiv', diff --git a/lang/en/help.php b/lang/en/help.php new file mode 100644 index 0000000..2616940 --- /dev/null +++ b/lang/en/help.php @@ -0,0 +1,18 @@ + 'Help — Clusev', + 'eyebrow' => 'Help', + 'heading' => 'Help & documentation', + 'subtitle' => 'Settings and procedures explained.', + + 'topic_overview' => 'Overview', + 'topic_domain_tls' => 'Domain, TLS & reverse proxy', + 'topic_security' => 'Security & 2FA', + 'topic_updates' => 'Updates & versions', + 'topic_servers' => 'Servers & SSH', + 'topic_sessions' => 'Sessions & users', + 'topic_email' => 'Email (SMTP)', + 'topic_audit' => 'Audit log', + 'topic_recovery' => 'Account recovery', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 02f33ad..7855de9 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -21,6 +21,7 @@ return [ 'nav_settings' => 'Settings', 'nav_system' => 'System', 'nav_versions' => 'Version', + 'nav_help' => 'Help', // Sidebar — user / 2FA badge 'twofa_on' => '2FA on', diff --git a/resources/js/app.js b/resources/js/app.js index 3b7ad81..1a4f8ce 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -253,7 +253,7 @@ document.addEventListener('alpine:init', () => { // translated via __()), so the palette stays in sync with the active locale. // CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log, // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS. - const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' }; + const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help' }; 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 b54f511..6185b51 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -16,6 +16,7 @@ ['g e', __('shell.nav_settings')], ['g y', __('shell.nav_system')], ['g v', __('shell.nav_versions')], + ['g h', __('shell.nav_help')], ]; $nav = [ @@ -27,6 +28,7 @@ ['label' => __('shell.nav_settings'), 'href' => '/settings', 'hint' => 'g e'], ['label' => __('shell.nav_system'), 'href' => '/system', 'hint' => 'g y'], ['label' => __('shell.nav_versions'), 'href' => '/versions', 'hint' => 'g v'], + ['label' => __('shell.nav_help'), 'href' => '/help', 'hint' => 'g h'], ]; $actions = [ diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 2bb8c17..32038a6 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -5,6 +5,7 @@ 'menu' => '', 'chevron-left' => '', 'tag' => '', + 'help-circle' => '', 'lock' => '', 'lock-open' => '', 'git-branch' => '', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index fab3209..4354517 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -34,6 +34,7 @@ {{ __('shell.nav_settings') }} {{ __('shell.nav_system') }} {{ __('shell.nav_versions') }} + {{ __('shell.nav_help') }} {{-- User --}} diff --git a/resources/views/livewire/help/content/de/overview.blade.php b/resources/views/livewire/help/content/de/overview.blade.php new file mode 100644 index 0000000..de0fd97 --- /dev/null +++ b/resources/views/livewire/help/content/de/overview.blade.php @@ -0,0 +1,46 @@ +@php + $h = 'font-display text-base font-semibold text-ink'; + $p = 'text-sm leading-relaxed text-ink-2'; + $li = 'text-sm leading-relaxed text-ink-2'; + $code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text'; +@endphp + +
+

Was ist Clusev?

+

Clusev ist ein selbst-gehostetes Control-Panel, um eine Flotte von Linux-Servern aus einem + Dashboard zu verwalten — agentenlos über SSH. Es installiert nichts auf den Zielservern, sondern steuert sie + direkt (Befehle + Dateien über SSH). Die ganze Oberfläche ist sicherheitsorientiert: 2FA, verschlüsselter + SSH-Zugangsdaten-Tresor und ein vollständiges Audit-Log.

+
+ +
+

Erster Login

+

Nach der Installation meldest du dich mit der Admin-E-Mail und dem Standard-Passwort + clusev an (die Standard-E-Mail ist admin@clusev.local, + sofern beim Installieren keine andere gesetzt wurde). Das Panel erzwingt sofort einen + Passwortwechsel — `clusev` ist ein bekanntes Standard-Passwort und nur bis zur Änderung gültig. Ändere es + gleich beim ersten Login.

+
+ +
+

Zugriff: Bare-IP oder Domain

+
    +
  • Ohne Domain (Bare-IP): erreichbar über + http://<Server-IP> per Klartext-HTTP. Diese Adresse bleibt immer als + Wiederherstellungsweg erreichbar, auch wenn später eine Domain eingerichtet wird.
  • +
  • Mit Domain: entweder mit eingebautem TLS (Let's Encrypt, + automatisch) oder hinter einem externen Reverse-Proxy. Details im Thema „Domain, TLS & Reverse-Proxy".
  • +
+
+ +
+

Wo finde ich was?

+
    +
  • Dashboard: Live-Metriken der Flotte.
  • +
  • Server / Dienste / Dateien: Server-Details, systemd-Dienste, SFTP-Dateimanager.
  • +
  • Audit-Log: wer hat wann was gemacht.
  • +
  • Einstellungen: Profil, Sicherheit/2FA, Benutzer, Sitzungen, E-Mail.
  • +
  • System: Domain, TLS & Neustart. Version: Updates. Hilfe: diese Seite.
  • +
+

Tipp: Strg/⌘ K öffnet die Befehls-Suche; mit g + Buchstabe springst du direkt (z. B. g h = Hilfe).

+
diff --git a/resources/views/livewire/help/content/en/overview.blade.php b/resources/views/livewire/help/content/en/overview.blade.php new file mode 100644 index 0000000..cd97f1d --- /dev/null +++ b/resources/views/livewire/help/content/en/overview.blade.php @@ -0,0 +1,44 @@ +@php + $h = 'font-display text-base font-semibold text-ink'; + $p = 'text-sm leading-relaxed text-ink-2'; + $li = 'text-sm leading-relaxed text-ink-2'; + $code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text'; +@endphp + +
+

What is Clusev?

+

Clusev is a self-hosted control panel for managing a fleet of Linux servers from one dashboard — + agentless, over SSH. It installs nothing on the target servers; it drives them directly (commands + files over + SSH). The whole interface is security-first: 2FA, an encrypted SSH-credential vault and a complete audit log.

+
+ +
+

First login

+

After installation, sign in with the admin e-mail and the default password + clusev (the default e-mail is admin@clusev.local + unless another was set at install time). The panel forces a password change immediately — + `clusev` is a known default and only valid until you change it. Change it right away on first login.

+
+ +
+

Access: bare IP or a domain

+
    +
  • No domain (bare IP): reachable at + http://<server-ip> over plain HTTP. This address always stays reachable + as a recovery path, even after a domain is configured.
  • +
  • With a domain: either built-in TLS (Let's Encrypt, + automatic) or behind an external reverse proxy. See the "Domain, TLS & reverse proxy" topic.
  • +
+
+ +
+

Where to find things

+
    +
  • Dashboard: live fleet metrics.
  • +
  • Servers / Services / Files: server details, systemd services, SFTP file manager.
  • +
  • Audit log: who did what, when.
  • +
  • Settings: profile, security/2FA, users, sessions, e-mail.
  • +
  • System: domain, TLS & restart. Version: updates. Help: this page.
  • +
+

Tip: Ctrl/⌘ K opens the command search; g + a letter jumps directly (e.g. g h = Help).

+
diff --git a/resources/views/livewire/help/index.blade.php b/resources/views/livewire/help/index.blade.php new file mode 100644 index 0000000..d62c41c --- /dev/null +++ b/resources/views/livewire/help/index.blade.php @@ -0,0 +1,35 @@ +@php + // Resolve the content partial for the active locale; fall back to German if an EN partial + // is missing, so a not-yet-translated topic never breaks the page. + $locale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de'; + $partial = 'livewire.help.content.'.$locale.'.'.$topic; + if (! view()->exists($partial)) { + $partial = 'livewire.help.content.de.'.$topic; + } +@endphp + +
+
+

{{ __('help.eyebrow') }}

+

{{ __('help.heading') }}

+

{{ __('help.subtitle') }}

+
+ +
+ {{-- Topic navigation (wraps above the content on small screens) --}} + + + {{-- Content --}} +
+ @include($partial) +
+
+
diff --git a/routes/web.php b/routes/web.php index ea3f3c3..9f641be 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Livewire\Audit; use App\Livewire\Auth; use App\Livewire\Dashboard; use App\Livewire\Files; +use App\Livewire\Help; use App\Livewire\Servers; use App\Livewire\Services; use App\Livewire\Settings; @@ -104,5 +105,6 @@ Route::middleware('auth')->group(function () { Route::get('/settings', Settings\Index::class)->name('settings'); Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); + Route::get('/help', Help\Index::class)->name('help'); }); }); diff --git a/tests/Feature/HelpPageTest.php b/tests/Feature/HelpPageTest.php new file mode 100644 index 0000000..0b4db15 --- /dev/null +++ b/tests/Feature/HelpPageTest.php @@ -0,0 +1,39 @@ +actingAs(User::factory()->create(['must_change_password' => false])); + } + + public function test_help_route_loads_for_an_authenticated_admin(): void + { + $this->actAsAdmin(); + $this->get('/help')->assertOk(); + } + + public function test_default_topic_is_overview(): void + { + $this->actAsAdmin(); + Livewire::test(Index::class) + ->assertSet('topic', 'overview') + ->assertSee(__('help.topic_overview')); + } + + public function test_unknown_topic_falls_back_to_overview(): void + { + $this->actAsAdmin(); + Livewire::test(Index::class, ['topic' => 'bogus'])->assertSet('topic', 'overview'); + } +}