diff --git a/CLAUDE.md b/CLAUDE.md index 9348eaa..c34bde8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,8 +144,10 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups: ## 6. Conventions -- **UI copy: German**, terse/operational, **no emoji** (status = color/dots/pills). Native - technical tokens stay as-is (`nginx.service`, `chmod 600`, `SSH`, `2FA`). (R9) +- **UI copy is localized (DE default + EN)** — never hard-code visible strings; use + `__('group.key')` with translations in `lang/{de,en}/.php` (one group per feature + + shared `common`), keys identical in both languages. Register: terse/operational, **no emoji** + (status = color/dots/pills). Native technical tokens stay as-is (`nginx.service`, `SSH`, `2FA`). (R9, R16) - **Colors:** only `@theme` token utilities in markup — never raw hex/rgb. (R3) - **Inline styles:** forbidden except a progress bar's `width`. (R4) - **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show` diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..2242bbf --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,31 @@ + directories + R16. */ + public const SUPPORTED = ['de', 'en']; + + public function handle(Request $request, Closure $next): Response + { + $locale = $request->user()?->locale + ?? $request->session()->get('locale') + ?? config('app.locale'); + + if (in_array($locale, self::SUPPORTED, true)) { + app()->setLocale($locale); + } + + return $next($request); + } +} diff --git a/app/Livewire/Audit/Index.php b/app/Livewire/Audit/Index.php index bea14fe..bd7e8f4 100644 --- a/app/Livewire/Audit/Index.php +++ b/app/Livewire/Audit/Index.php @@ -5,11 +5,9 @@ namespace App\Livewire\Audit; use App\Models\AuditEvent; use Illuminate\Support\Collection; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Component; #[Layout('layouts.app')] -#[Title('Audit-Log — Clusev')] class Index extends Component { /** Freitext-Filter über Akteur / Aktion / Ziel / Server. */ @@ -47,6 +45,6 @@ class Index extends Component { return view('livewire.audit.index', [ 'events' => $this->events(), - ]); + ])->title(__('audit.title')); } } diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php index 523701d..7bccea0 100644 --- a/app/Livewire/Auth/Login.php +++ b/app/Livewire/Auth/Login.php @@ -8,12 +8,10 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Attributes\Validate; use Livewire\Component; #[Layout('layouts.auth')] -#[Title('Anmelden — Clusev')] class Login extends Component { #[Validate('required|email')] @@ -32,7 +30,7 @@ class Login extends Component if (RateLimiter::tooManyAttempts($key, 5)) { throw ValidationException::withMessages([ - 'email' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.', + 'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), ]); } @@ -40,7 +38,7 @@ class Login extends Component if (! $user || ! Hash::check($this->password, $user->password)) { RateLimiter::hit($key, 60); - throw ValidationException::withMessages(['email' => 'Diese Zugangsdaten passen nicht.']); + throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]); } RateLimiter::clear($key); @@ -61,6 +59,6 @@ class Login extends Component public function render() { - return view('livewire.auth.login'); + return view('livewire.auth.login')->title(__('auth.title_login')); } } diff --git a/app/Livewire/Auth/PasswordChange.php b/app/Livewire/Auth/PasswordChange.php index e6b94a5..d6c0565 100644 --- a/app/Livewire/Auth/PasswordChange.php +++ b/app/Livewire/Auth/PasswordChange.php @@ -6,11 +6,9 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Component; #[Layout('layouts.auth')] -#[Title('Passwort ändern — Clusev')] class PasswordChange extends Component { public string $current = ''; @@ -25,8 +23,8 @@ class PasswordChange extends Component 'current' => ['required', 'current_password'], 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], ], attributes: [ - 'current' => 'aktuelles Passwort', - 'password' => 'neues Passwort', + 'current' => __('auth.attr_current_password'), + 'password' => __('auth.attr_new_password'), ]); Auth::user()->forceFill([ @@ -40,6 +38,6 @@ class PasswordChange extends Component public function render() { - return view('livewire.auth.password-change'); + return view('livewire.auth.password-change')->title(__('auth.title_password_change')); } } diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index df8e314..32187db 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -7,13 +7,11 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Attributes\Validate; use Livewire\Component; use PragmaRX\Google2FAQRCode\Google2FA; #[Layout('layouts.auth')] -#[Title('Bestätigung — Clusev')] class TwoFactorChallenge extends Component { #[Validate('required|string')] @@ -34,7 +32,7 @@ class TwoFactorChallenge extends Component if (RateLimiter::tooManyAttempts($key, 5)) { throw ValidationException::withMessages([ - 'code' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.', + 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), ]); } @@ -42,14 +40,14 @@ class TwoFactorChallenge extends Component if (! $user || ! $user->hasTwoFactorEnabled()) { session()->forget(['2fa.user', '2fa.remember']); - throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']); + throw ValidationException::withMessages(['code' => __('auth.session_expired')]); } $valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); if (! $valid) { RateLimiter::hit($key, 60); - throw ValidationException::withMessages(['code' => 'Ungültiger Code.']); + throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); } RateLimiter::clear($key); @@ -65,6 +63,6 @@ class TwoFactorChallenge extends Component public function render() { - return view('livewire.auth.two-factor-challenge'); + return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge')); } } diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php index 2befb40..273c1a2 100644 --- a/app/Livewire/Auth/TwoFactorSetup.php +++ b/app/Livewire/Auth/TwoFactorSetup.php @@ -5,13 +5,11 @@ namespace App\Livewire\Auth; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Attributes\Validate; use Livewire\Component; use PragmaRX\Google2FAQRCode\Google2FA; #[Layout('layouts.auth')] -#[Title('2FA einrichten — Clusev')] class TwoFactorSetup extends Component { public string $secret = ''; @@ -33,7 +31,7 @@ class TwoFactorSetup extends Component $this->validate(); if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) { - throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']); + throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]); } Auth::user()->forceFill([ @@ -51,6 +49,6 @@ class TwoFactorSetup extends Component public function render() { - return view('livewire.auth.two-factor-setup'); + return view('livewire.auth.two-factor-setup')->title(__('auth.title_setup')); } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 28b3534..56edc26 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -8,18 +8,22 @@ use App\Models\Server; use App\Services\FleetService; use Illuminate\Support\Facades\Cache; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Component; use Throwable; #[Layout('layouts.app')] -#[Title('Clusev — Dashboard')] class Dashboard extends Component { use WithFleetContext; public bool $ready = false; + /** Page ; localized at runtime (attributes can't call __()). */ + public function title(): string + { + return __('dashboard.page_title'); + } + /** @var array<int, array> notable systemd units (lazy-loaded over SSH) */ public array $svcRows = []; @@ -124,7 +128,7 @@ class Dashboard extends Component 'diskSub' => isset($latest['disk_total']) && $latest['disk_total'] > 0 ? $latest['disk_used'].' / '.$latest['disk_total'].' GB' : null, - 'loadSub' => $cores.' '.($cores === 1 ? 'Kern' : 'Kerne'), + 'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')), 'services' => $this->svcRows, 'events' => AuditEvent::with('server')->latest()->limit(6)->get(), ]); diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index f6b55e3..0dbd4d4 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -6,13 +6,11 @@ use App\Livewire\Concerns\WithFleetContext; use App\Services\FleetService; use Livewire\Attributes\Layout; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; use Livewire\WithFileUploads; use Throwable; #[Layout('layouts.app')] -#[Title('Dateien — Clusev')] class Index extends Component { use WithFileUploads, WithFleetContext; @@ -87,9 +85,9 @@ class Index extends Component try { $name = basename($this->upload->getClientOriginalName()); $fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath()); - $this->dispatch('notify', message: "„{$name}“ hochgeladen."); + $this->dispatch('notify', message: __('files.uploaded', ['name' => $name])); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Upload fehlgeschlagen: '.$e->getMessage()); + $this->dispatch('notify', message: __('files.upload_failed', ['error' => $e->getMessage()])); } } @@ -175,16 +173,16 @@ class Index extends Component $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ - 'heading' => 'Datei löschen', - 'body' => "„{$name}“ wird unwiderruflich aus {$this->path} entfernt.", - 'confirmLabel' => 'Löschen', + 'heading' => __('files.delete_heading'), + 'body' => __('files.delete_body', ['name' => $name, 'path' => $this->path]), + 'confirmLabel' => __('common.delete'), 'danger' => true, 'icon' => 'trash', 'auditAction' => 'file.delete', 'auditTarget' => rtrim($this->path, '/')."/{$name}", 'event' => 'fileConfirmed', 'params' => ['name' => $name], - 'notify' => "„{$name}“ gelöscht.", + 'notify' => __('files.delete_notify', ['name' => $name]), ], ); } @@ -199,7 +197,7 @@ class Index extends Component try { $fleet->deleteFile($active, rtrim($this->path, '/').'/'.$name); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Löschen fehlgeschlagen: '.$e->getMessage()); + $this->dispatch('notify', message: __('files.delete_failed', ['error' => $e->getMessage()])); return; } @@ -210,6 +208,6 @@ class Index extends Component public function render() { - return view('livewire.files.index'); + return view('livewire.files.index')->title(__('files.title')); } } diff --git a/app/Livewire/Servers/Index.php b/app/Livewire/Servers/Index.php index f4d824c..3f55575 100644 --- a/app/Livewire/Servers/Index.php +++ b/app/Livewire/Servers/Index.php @@ -6,16 +6,20 @@ use App\Models\Server; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; #[Layout('layouts.app')] -#[Title('Server — Clusev')] class Index extends Component { /** Live search over name / IP. */ public string $search = ''; + /** Page <title>; localized at runtime (attributes can't call __()). */ + public function title(): string + { + return __('servers.index_title'); + } + /** A freshly added server should appear without a manual reload (render() re-queries). */ #[On('serverCreated')] public function refreshServers(): void diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index 8397f01..b3dd92d 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -13,17 +13,21 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; use Livewire\Attributes\Layout; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; use Throwable; #[Layout('layouts.app')] -#[Title('Server-Details — Clusev')] class Show extends Component { /** Route-model-bound by uuid (R11). */ public Server $server; + /** Page <title>; localized at runtime (attributes can't call __()). */ + public function title(): string + { + return __('servers.show_title'); + } + public bool $connected = false; public bool $ready = false; @@ -163,9 +167,9 @@ class Show extends Component $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ - 'heading' => 'SSH-Schlüssel entfernen', - 'body' => "Der Schlüssel „{$comment}“ verliert den Zugang zu {$this->server->name}.", - 'confirmLabel' => 'Entfernen', + 'heading' => __('servers.confirm_remove_key_heading'), + 'body' => __('servers.confirm_remove_key_body', ['name' => $comment, 'server' => $this->server->name]), + 'confirmLabel' => __('common.remove'), 'danger' => true, 'icon' => 'trash', 'auditAction' => 'ssh_key.remove', @@ -173,7 +177,7 @@ class Show extends Component 'serverId' => $this->server->id, 'event' => 'keyRemoved', 'params' => ['fingerprint' => $fingerprint], - 'notify' => "Schlüssel „{$comment}“ entfernt.", + 'notify' => __('servers.notify_key_removed', ['name' => $comment]), ], ); } @@ -185,7 +189,7 @@ class Show extends Component try { $fleet->removeAuthorizedKey($this->server, $fingerprint); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.$e->getMessage()); + $this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => $e->getMessage()])); return; } @@ -246,7 +250,7 @@ class Show extends Component ]); $this->server->refresh(); - $this->dispatch('notify', message: $disable ? 'Zugang gesperrt.' : 'Zugang entsperrt.'); + $this->dispatch('notify', message: $disable ? __('servers.notify_access_locked') : __('servers.notify_access_unlocked')); } /** @@ -263,9 +267,9 @@ class Show extends Component $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ - 'heading' => 'Zugang löschen', - 'body' => "Der hinterlegte SSH-Zugang für {$this->server->name} wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.", - 'confirmLabel' => 'Löschen', + 'heading' => __('servers.confirm_delete_credential_heading'), + 'body' => __('servers.confirm_delete_credential_body', ['server' => $this->server->name]), + 'confirmLabel' => __('common.delete'), 'danger' => true, 'icon' => 'trash', 'auditAction' => 'credential.delete', @@ -273,7 +277,7 @@ class Show extends Component 'serverId' => $this->server->id, 'event' => 'credentialDeleted', 'params' => [], - 'notify' => 'Zugang gelöscht.', + 'notify' => __('servers.notify_access_deleted'), ], ); } @@ -314,9 +318,9 @@ class Show extends Component $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ - 'heading' => 'Firewall-Regel entfernen', - 'body' => "Die Regel „{$label}“ wird aus der Firewall von {$this->server->name} entfernt.", - 'confirmLabel' => 'Entfernen', + 'heading' => __('servers.confirm_remove_rule_heading'), + 'body' => __('servers.confirm_remove_rule_body', ['label' => $label, 'server' => $this->server->name]), + 'confirmLabel' => __('common.remove'), 'danger' => true, 'icon' => 'trash', // No premature audit/notify — the handler records the real outcome @@ -335,21 +339,21 @@ class Show extends Component try { $res = $firewall->deleteRule($this->server, $spec); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($e->getMessage(), 90)); + $this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($e->getMessage(), 90)])); return; } // The rule was already gone — inform, refresh, but do NOT audit a non-event. if (! empty($res['notFound'])) { - $this->dispatch('notify', message: 'Regel existiert nicht mehr.'); + $this->dispatch('notify', message: __('servers.notify_rule_not_found')); $this->reloadFirewall($firewall); return; } if (! $res['ok']) { - $this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90)); + $this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)])); return; } @@ -363,7 +367,7 @@ class Show extends Component 'ip' => request()->ip(), ]); - $this->dispatch('notify', message: 'Firewall-Regel entfernt.'); + $this->dispatch('notify', message: __('servers.notify_firewall_rule_removed')); $this->reloadFirewall($firewall); } @@ -384,18 +388,18 @@ class Show extends Component try { $res = $fail2ban->unban($this->server, $jail, $ip); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($e->getMessage(), 90)); + $this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)])); return; } if (! $res['ok']) { - $this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90)); + $this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)])); return; } $this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name); - $this->dispatch('notify', message: "IP {$ip} entsperrt."); + $this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip])); $this->reloadFail2ban($fail2ban); } @@ -410,12 +414,12 @@ class Show extends Component try { $res = $fail2ban->addIgnoreIp($this->server, $ip); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90)); + $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)])); return; } if (! $res['ok']) { - $this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90)); + $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)])); return; } @@ -428,7 +432,7 @@ class Show extends Component $this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name); $this->newIgnoreIp = ''; - $this->dispatch('notify', message: "{$ip} zur Whitelist hinzugefügt."); + $this->dispatch('notify', message: __('servers.notify_ip_whitelisted', ['ip' => $ip])); $this->reloadFail2ban($fail2ban); } @@ -438,12 +442,12 @@ class Show extends Component try { $res = $fail2ban->removeIgnoreIp($this->server, $ip); } catch (Throwable $e) { - $this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90)); + $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)])); return; } if (! $res['ok']) { - $this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90)); + $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)])); return; } @@ -454,7 +458,7 @@ class Show extends Component } $this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name); - $this->dispatch('notify', message: "{$ip} von der Whitelist entfernt."); + $this->dispatch('notify', message: __('servers.notify_ip_unwhitelisted', ['ip' => $ip])); $this->reloadFail2ban($fail2ban); } diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php index e70103c..3c3ea0d 100644 --- a/app/Livewire/Services/Index.php +++ b/app/Livewire/Services/Index.php @@ -7,12 +7,10 @@ use App\Services\FleetService; use Illuminate\Support\Str; use Livewire\Attributes\Layout; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; use Throwable; #[Layout('layouts.app')] -#[Title('Dienste — Clusev')] class Index extends Component { use WithFleetContext; @@ -70,16 +68,16 @@ class Index extends Component } [$heading, $phrase, $label, $action, $icon] = match ($op) { - 'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'], - 'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'], - default => ['Dienst neu starten', 'wird neu gestartet', 'Neu starten', 'service.restart', 'rotate'], + 'start' => [__('services.confirm_start_heading'), __('services.confirm_start_phrase'), __('services.confirm_start_label'), 'service.start', 'power'], + 'stop' => [__('services.confirm_stop_heading'), __('services.confirm_stop_phrase'), __('services.confirm_stop_label'), 'service.stop', 'power'], + default => [__('services.confirm_restart_heading'), __('services.confirm_restart_phrase'), __('services.confirm_restart_label'), 'service.restart', 'rotate'], }; $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => $heading, - 'body' => "{$name} {$phrase}. Ausgeführt über systemctl auf {$this->server}.", + 'body' => __('services.confirm_body', ['name' => $name, 'phrase' => $phrase, 'server' => $this->server]), 'confirmLabel' => $label, 'danger' => $op === 'stop', 'icon' => $icon, @@ -87,7 +85,7 @@ class Index extends Component 'auditTarget' => "{$name} · {$this->server}", 'event' => 'serviceConfirmed', 'params' => ['op' => $op, 'name' => $name], - 'notify' => "{$name}: {$label} ausgeführt.", + 'notify' => __('services.confirm_notify', ['name' => $name, 'label' => $label]), ], ); } @@ -105,14 +103,14 @@ class Index extends Component try { $res = $fleet->serviceAction($active, $op, $name); } catch (Throwable $e) { - $this->dispatch('notify', message: "{$name}: ".$e->getMessage()); + $this->dispatch('notify', message: __('services.action_error', ['name' => $name, 'error' => $e->getMessage()])); return; } $this->dispatch('notify', message: $res['ok'] - ? "{$name}: {$op} ausgeführt." - : "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90)); + ? __('services.action_done', ['name' => $name, 'op' => $op]) + : __('services.action_failed', ['name' => $name, 'output' => Str::limit($res['output'] ?: __('services.no_permission'), 90)])); try { $this->services = $fleet->systemd($active)['services']; @@ -139,6 +137,6 @@ class Index extends Component public function render() { - return view('livewire.services.index'); + return view('livewire.services.index')->title(__('services.title')); } } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index df5dcf3..9393eb5 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -8,11 +8,9 @@ use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Layout; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; #[Layout('layouts.app')] -#[Title('Einstellungen — Clusev')] class Index extends Component { public string $tab = 'profile'; @@ -42,7 +40,7 @@ class Index extends Component Auth::user()->update($data); $this->audit('profile.update', $data['email']); - $this->dispatch('notify', message: 'Profil gespeichert.'); + $this->dispatch('notify', message: __('settings.profile_saved')); } public function updatePassword(): void @@ -55,7 +53,7 @@ class Index extends Component Auth::user()->update(['password' => $this->password, 'must_change_password' => false]); $this->reset('current_password', 'password', 'password_confirmation'); $this->audit('password.change', Auth::user()->email); - $this->dispatch('notify', message: 'Passwort geändert.'); + $this->dispatch('notify', message: __('settings.password_changed')); } public function confirmDisableTwoFactor(): void @@ -63,15 +61,15 @@ class Index extends Component $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ - 'heading' => '2FA deaktivieren', - 'body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.', - 'confirmLabel' => 'Deaktivieren', + 'heading' => __('settings.disable_2fa_heading'), + 'body' => __('settings.disable_2fa_body'), + 'confirmLabel' => __('common.disable'), 'danger' => true, 'icon' => 'shield', 'auditAction' => 'two_factor.disable', 'auditTarget' => Auth::user()?->email, 'event' => 'twoFactorDisabled', - 'notify' => '2FA deaktiviert.', + 'notify' => __('settings.disable_2fa_notify'), ], ); } @@ -100,6 +98,6 @@ class Index extends Component { return view('livewire.settings.index', [ 'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(), - ]); + ])->title(__('settings.title')); } } diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index f300e32..c727b65 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -6,7 +6,6 @@ use App\Models\Setting; use App\Services\DeploymentService; use Livewire\Attributes\Layout; use Livewire\Attributes\On; -use Livewire\Attributes\Title; use Livewire\Component; /** @@ -21,14 +20,19 @@ use Livewire\Component; * config('clusev.channel')), audited on change. */ #[Layout('layouts.app')] -#[Title('System — Clusev')] class Index extends Component { - /** Valid user-facing release channels with their German descriptions. */ - public const CHANNELS = [ - 'stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.', - 'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.', - ]; + /** Valid user-facing release channels (descriptions are localized at render time). */ + public const CHANNELS = ['stable', 'beta']; + + /** Channel key => localized description, built per-request for the view. */ + private function channelDescriptions(): array + { + return [ + 'stable' => __('system.channel_stable'), + 'beta' => __('system.channel_beta'), + ]; + } /** Install-time panel domain (read-only display). */ public string $domain = ''; @@ -40,29 +44,29 @@ class Index extends Component $this->domain = (string) ($deployment->domain() ?? ''); $channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable'; // Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable. - $this->channel = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable'; + $this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable'; } /** Release channel changes are audited (confirmation via the shared modal). */ public function confirmChannel(string $channel): void { - if (! array_key_exists($channel, self::CHANNELS) || $channel === $this->channel) { + if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) { return; } $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ - 'heading' => 'Release-Kanal wechseln', - 'body' => 'Der Release-Kanal wird auf "'.$channel.'" gesetzt. Updates werden dann aus dieser Quelle bezogen.', - 'confirmLabel' => 'Wechseln', + 'heading' => __('system.change_channel_heading'), + 'body' => __('system.change_channel_body', ['channel' => $channel]), + 'confirmLabel' => __('system.change_channel_confirm'), 'danger' => false, 'icon' => 'git-branch', 'auditAction' => 'system.settings_updated', 'auditTarget' => 'release_channel='.$channel, 'event' => 'channelChanged', 'params' => ['channel' => $channel], - 'notify' => 'Release-Kanal auf "'.$channel.'" gesetzt.', + 'notify' => __('system.change_channel_notify', ['channel' => $channel]), ], ); } @@ -70,7 +74,7 @@ class Index extends Component #[On('channelChanged')] public function applyChannel(string $channel): void { - if (! array_key_exists($channel, self::CHANNELS)) { + if (! in_array($channel, self::CHANNELS, true)) { return; } @@ -83,7 +87,7 @@ class Index extends Component return view('livewire.system.index', [ 'hasTls' => $this->domain !== '', 'panelUrl' => $deployment->panelUrl(), - 'channels' => self::CHANNELS, - ]); + 'channels' => $this->channelDescriptions(), + ])->title(__('system.title')); } } diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 44b3e8a..59dcef5 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -4,7 +4,6 @@ namespace App\Livewire\Versions; use App\Models\Setting; use Livewire\Attributes\Layout; -use Livewire\Attributes\Title; use Livewire\Component; /** @@ -16,7 +15,6 @@ use Livewire\Component; * against the newest tag in the configured channel. */ #[Layout('layouts.app')] -#[Title('Version — Clusev')] class Index extends Component { /** Only these channels are ever offered to users — there is no 'dev' channel. */ @@ -43,13 +41,13 @@ class Index extends Component if ($latest === null) { $this->updateState = 'current'; - $this->updateStatus = 'Noch kein Release getaggt — installierte Version v'.$installed.' (Kanal: '.$channel.').'; + $this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]); } elseif ($this->isNewer($latest, $installed)) { $this->updateState = 'update'; - $this->updateStatus = 'Update verfügbar: v'.$latest.' (Kanal: '.$channel.').'; + $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]); } else { $this->updateState = 'current'; - $this->updateStatus = 'Aktuell — v'.$installed.' (Kanal: '.$channel.').'; + $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]); } } @@ -228,6 +226,6 @@ class Index extends Component 'build' => $this->build(), 'releases' => $releases, 'latestTag' => $latestTag, - ]); + ])->title(__('versions.title')); } } diff --git a/app/Models/User.php b/app/Models/User.php index 5e98458..6d9720a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -#[Fillable(['name', 'email', 'password', 'must_change_password'])] +#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])] #[Hidden(['password', 'remember_token', 'two_factor_secret'])] class User extends Authenticatable { diff --git a/bootstrap/app.php b/bootstrap/app.php index 775adfd..b1d0a33 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ <?php use App\Http\Middleware\SecurityHeaders; +use App\Http\Middleware\SetLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -14,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ + SetLocale::class, SecurityHeaders::class, ]); }) diff --git a/config/app.php b/config/app.php index 423eed5..aad3922 100644 --- a/config/app.php +++ b/config/app.php @@ -78,9 +78,9 @@ return [ | */ - 'locale' => env('APP_LOCALE', 'en'), + 'locale' => env('APP_LOCALE', 'de'), - 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'de'), 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), diff --git a/database/migrations/2026_06_13_030000_add_locale_to_users.php b/database/migrations/2026_06_13_030000_add_locale_to_users.php new file mode 100644 index 0000000..543aacd --- /dev/null +++ b/database/migrations/2026_06_13_030000_add_locale_to_users.php @@ -0,0 +1,23 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** Per-user UI language preference (de|en). Null = follow the app default. */ + public function up(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('locale', 5)->nullable()->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/lang/de/audit.php b/lang/de/audit.php new file mode 100644 index 0000000..e8f4cf3 --- /dev/null +++ b/lang/de/audit.php @@ -0,0 +1,23 @@ +<?php + +// Audit log page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'Sicherheit', + 'heading' => 'Audit-Log', + 'event_count' => ':count Ereignisse', + + // Events panel + 'panel_title' => 'Ereignisse', + 'panel_subtitle' => 'letzte 50 · neueste zuerst', + 'search_label' => 'Audit-Log durchsuchen', + 'search_placeholder' => 'Akteur, Aktion, Ziel …', + + // Empty state + 'empty_title' => 'Keine Ereignisse', + 'empty_filtered' => 'Für „:query“ wurden keine Audit-Einträge gefunden.', + 'empty_none' => 'Es liegen noch keine Audit-Einträge vor.', + + // Page title + 'title' => 'Audit-Log — Clusev', +]; diff --git a/lang/de/auth.php b/lang/de/auth.php new file mode 100644 index 0000000..8ebbb27 --- /dev/null +++ b/lang/de/auth.php @@ -0,0 +1,74 @@ +<?php + +// Auth feature strings (login, password change, 2FA challenge + setup, auth brand panel). +// Shared actions/status live in common.php (R16). de = original German verbatim. +return [ + // ── Login ──────────────────────────────────────────────────────────── + 'control_plane' => 'Control Plane', + 'login_heading' => 'Anmelden', + 'login_subtitle' => 'Zugang zum Clusev-Panel.', + 'email' => 'E-Mail', + 'password' => 'Passwort', + 'remember_me' => 'Angemeldet bleiben', + 'login_submit' => 'Anmelden', + 'checking' => 'Prüfe…', + 'secured' => 'Gesichert', + 'security_note' => 'Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log.', + + // ── Password change ────────────────────────────────────────────────── + 'security' => 'Sicherheit', + 'password_change_heading' => 'Passwort ändern', + 'password_change_subtitle' => 'Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).', + 'current_password' => 'Aktuelles Passwort', + 'new_password' => 'Neues Passwort', + 'confirm_new_password' => 'Neues Passwort bestätigen', + 'save_and_continue' => 'Speichern & weiter', + 'saving' => 'Speichere…', + + // ── Two-factor (shared label) ──────────────────────────────────────── + 'two_factor' => 'Zwei-Faktor', + + // ── 2FA challenge ──────────────────────────────────────────────────── + 'challenge_heading' => 'Bestätigung', + 'challenge_subtitle' => '6-stelliger Code aus deiner Authenticator-App.', + 'code' => 'Code', + 'back_to_login' => 'Zurück zur Anmeldung', + + // ── 2FA setup ──────────────────────────────────────────────────────── + 'setup_heading' => 'Einrichten', + 'setup_subtitle' => 'Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, …) und bestätige mit einem Code.', + 'qr_alt' => '2FA-QR-Code', + 'secret_manual' => 'Geheimnis (manuell)', + 'confirmation_code' => 'Bestätigungs-Code', + 'activate' => 'Aktivieren', + + // ── Brand panel (auth layout) ──────────────────────────────────────── + 'brand_headline_lead' => 'Eine Konsole für deine', + 'brand_headline_accent' => 'gesamte Flotte', + 'brand_tagline' => 'Agentenlos über SSH. Metriken, Dienste, Dateien und ein lückenloses Audit-Log — sicher aus einem Panel gesteuert.', + 'terminal_title' => 'clusev · sicherheitsmodell', + 'terminal_ssh' => 'agentenlos · exec + SFTP über phpseclib', + 'terminal_key' => 'Host-Keys via TOFU gepinnt', + 'terminal_2fa' => 'erzwungen für jeden Login', + 'terminal_log' => 'jede Aktion im Audit-Log', + 'terminal_core' => 'Open Core · AGPL-lizenziert', + 'footer_copyright' => '© :year Clusev · Fleet Control', + 'footer_operational' => 'betriebsbereit', + + // ── Page titles ────────────────────────────────────────────────────── + 'title_login' => 'Anmelden — Clusev', + 'title_password_change' => 'Passwort ändern — Clusev', + 'title_challenge' => 'Bestätigung — Clusev', + 'title_setup' => '2FA einrichten — Clusev', + + // ── Validation / error messages ────────────────────────────────────── + 'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.', + 'invalid_credentials' => 'Diese Zugangsdaten passen nicht.', + 'session_expired' => 'Sitzung abgelaufen. Bitte erneut anmelden.', + 'invalid_code' => 'Ungültiger Code.', + 'code_mismatch' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.', + + // ── Validation attribute names ─────────────────────────────────────── + 'attr_current_password' => 'aktuelles Passwort', + 'attr_new_password' => 'neues Passwort', +]; diff --git a/lang/de/common.php b/lang/de/common.php new file mode 100644 index 0000000..615ee61 --- /dev/null +++ b/lang/de/common.php @@ -0,0 +1,43 @@ +<?php + +// Shared UI strings reused across the app (R16). Feature-specific strings live in +// their own group file (lang/<code>/<group>.php). Keep this list small and generic. +return [ + // Actions + 'save' => 'Speichern', + 'cancel' => 'Abbrechen', + 'close' => 'Schließen', + 'delete' => 'Löschen', + 'remove' => 'Entfernen', + 'add' => 'Hinzufügen', + 'edit' => 'Bearbeiten', + 'enable' => 'Aktivieren', + 'disable' => 'Deaktivieren', + 'lock' => 'Sperren', + 'unlock' => 'Entsperren', + 'back' => 'Zurück', + 'retry' => 'Erneut versuchen', + 'confirm' => 'Bestätigen', + 'open' => 'Öffnen', + 'more' => 'mehr', + 'apply' => 'Anwenden', + + // Status / state + 'online' => 'Online', + 'offline' => 'Offline', + 'warning' => 'Warnung', + 'active' => 'aktiv', + 'inactive' => 'inaktiv', + 'secure' => 'sicher', + 'unsupported' => 'n. v.', + 'unknown' => 'unbekannt', + 'loading' => 'Lädt…', + 'none' => '—', + + // Generic + 'search' => 'Suchen', + 'actions' => 'Aktionen', + 'server_not_found' => 'Server nicht gefunden.', + 'failed' => 'Fehlgeschlagen.', + 'done' => 'Erledigt.', +]; diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php new file mode 100644 index 0000000..3c950d9 --- /dev/null +++ b/lang/de/dashboard.php @@ -0,0 +1,33 @@ +<?php + +// Dashboard / live metrics view (R16). German = source of truth. +return [ + // Page + 'page_title' => 'Clusev — Dashboard', + 'heading' => 'Übersicht', + + // Service state labels (systemd ActiveState, operator wording) + 'svc_online' => 'aktiv', + 'svc_warning' => 'degradiert', + 'svc_offline' => 'gestoppt', + + // Utilisation chart + 'utilization' => 'Auslastung', + 'utilization_subtitle' => 'CPU & Memory · 15 Min', + 'axis_minus_15min' => '-15 Min', + 'axis_now' => 'jetzt', + + // systemd services panel + 'services_title' => 'systemd-Dienste', + 'col_state' => 'Zustand', + 'no_service_data' => 'Keine Dienstdaten — Server nicht verbunden.', + + // Audit panel + 'audit_title' => 'Audit-Log', + 'audit_subtitle' => 'letzte Ereignisse', + 'no_events' => 'Keine Ereignisse.', + + // Cores (CPU) + 'core' => 'Kern', + 'cores' => 'Kerne', +]; diff --git a/lang/de/files.php b/lang/de/files.php new file mode 100644 index 0000000..8f31d8d --- /dev/null +++ b/lang/de/files.php @@ -0,0 +1,36 @@ +<?php + +// File manager (SFTP) page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'Dateien', + 'heading' => 'Dateien', + 'sftp_connected' => 'SFTP verbunden', + 'sftp_disconnected' => 'nicht verbunden', + + // Listing panel + 'panel_subtitle' => ':dirs Ordner · :files Dateien', + 'upload' => 'Hochladen', + + // Column headers + 'col_name' => 'Name', + 'col_perms' => 'Rechte', + 'col_size' => 'Größe', + 'col_modified' => 'Geändert', + + // Row actions + 'download' => 'Download', + + // Upload / notifications + 'uploaded' => '„:name“ hochgeladen.', + 'upload_failed' => 'Upload fehlgeschlagen: :error', + + // Delete (confirm modal) + 'delete_heading' => 'Datei löschen', + 'delete_body' => '„:name“ wird unwiderruflich aus :path entfernt.', + 'delete_notify' => '„:name“ gelöscht.', + 'delete_failed' => 'Löschen fehlgeschlagen: :error', + + // Page title + 'title' => 'Dateien — Clusev', +]; diff --git a/lang/de/modals.php b/lang/de/modals.php new file mode 100644 index 0000000..a544efb --- /dev/null +++ b/lang/de/modals.php @@ -0,0 +1,170 @@ +<?php + +// Modal dialogs (wire-elements/modal). German = source of truth (R16). +// Keys grouped by modal via sub-key prefixes. Shared strings (Speichern, Abbrechen, +// Schließen, Hinzufügen, Anwenden, „Server nicht gefunden.", …) come from common.*. +return [ + // ── Add SSH key ─────────────────────────────────────────────────────── + 'add_ssh_key' => [ + 'title' => 'SSH-Schlüssel hinzufügen', + 'subtitle_before' => 'Öffentlichen Schlüssel einfügen — er wird in', + 'subtitle_after' => 'ergänzt.', + 'public_key_label' => 'Öffentlicher Schlüssel', + 'generate' => 'Neues Paar generieren', + 'generating' => 'generiere…', + 'public_key_placeholder' => 'ssh-ed25519 AAAA… kommentar — oder oben generieren', + 'private_key_warning' => 'Privater Schlüssel — erscheint nur jetzt, sicher speichern.', + 'notify_added' => 'SSH-Schlüssel hinzugefügt.', + ], + + // ── Create server ───────────────────────────────────────────────────── + 'create_server' => [ + 'title' => 'Server hinzufügen', + 'subtitle' => 'Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.', + 'name_label' => 'Name', + 'name_placeholder' => 'z. B. Prod-Web-01', + 'ip_host_label' => 'IP/Host', + 'ip_host_placeholder' => '10.10.90.10 oder host.example.com', + 'ssh_port_label' => 'SSH-Port', + 'user_label' => 'Benutzer', + 'auth_label' => 'Authentifizierung', + 'auth_password' => 'Passwort', + 'auth_key' => 'Privater Schlüssel', + 'secret_key_label' => 'Privater Schlüssel (PEM)', + 'secret_password_label' => 'Passwort', + 'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----', + 'passphrase_label' => 'Passphrase (optional)', + 'passphrase_placeholder' => 'leer = keine', + 'credential_name_label' => 'Zugangs-Name (optional)', + 'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User', + 'validation_ip_or_host' => 'Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.', + 'attr_name' => 'Name', + 'attr_ip' => 'IP/Host', + 'attr_ssh_port' => 'SSH-Port', + 'attr_user' => 'Benutzer', + 'attr_auth' => 'Authentifizierung', + 'attr_secret_key' => 'Privater Schlüssel', + 'attr_secret_password' => 'Passwort', + 'attr_credential_name' => 'Zugangs-Name', + 'notify_added' => 'Server hinzugefügt.', + ], + + // ── Edit credential ─────────────────────────────────────────────────── + 'edit_credential' => [ + 'title' => 'SSH-Zugang', + 'subtitle_before' => 'Hier den Zugang hinterlegen — z. B.', + 'subtitle_after' => 'für volle Steuerung (systemctl, Journal).', + 'name_label' => 'Name (optional)', + 'name_placeholder' => 'z. B. Prod-Server X · Backup-User', + 'user_label' => 'Benutzer', + 'auth_label' => 'Authentifizierung', + 'auth_password' => 'Passwort', + 'auth_key' => 'Privater Schlüssel', + 'secret_key_label' => 'Privater Schlüssel (PEM)', + 'secret_password_label' => 'Passwort', + 'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----', + 'passphrase_label' => 'Passphrase (optional)', + 'passphrase_placeholder' => 'leer = keine', + 'error_required' => 'Benutzer und Passwort/Schlüssel sind erforderlich.', + 'error_name_too_long' => 'Name darf höchstens 120 Zeichen lang sein.', + 'notify_saved' => 'Zugangsdaten gespeichert.', + ], + + // ── fail2ban: ban IP ────────────────────────────────────────────────── + 'fail2ban_ban' => [ + 'title' => 'IP manuell sperren', + 'subtitle' => 'Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang können nicht gesperrt werden.', + 'jail_label' => 'Jail', + 'ip_label' => 'IP-Adresse', + 'ip_placeholder' => 'z. B. 203.0.113.10', + 'submit' => 'IP sperren', + 'error_ban_failed' => 'Sperren fehlgeschlagen.', + 'notify_banned' => 'IP :ip gesperrt.', + ], + + // ── fail2ban: config ────────────────────────────────────────────────── + 'fail2ban_config' => [ + 'title' => 'fail2ban konfigurieren', + 'subtitle' => 'Legt fest, wann fail2ban auf :server eine IP sperrt und wie lange. Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre.', + 'this_server' => 'diesem Server', + 'bantime_label' => 'Sperrdauer', + 'bantime_placeholder' => 'z. B. 600, 10m, 1h, -1 = dauerhaft', + 'maxretry_label' => 'Max. Fehlversuche', + 'findtime_label' => 'Zeitfenster', + 'findtime_placeholder' => 'z. B. 600, 10m, 1h', + 'error_read_locked' => 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.', + 'error_bantime_invalid' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).', + 'error_findtime_invalid' => 'Ungültige Dauer (z. B. 600, 10m, 1h).', + 'error_save_failed' => 'Speichern fehlgeschlagen. :output', + 'attr_bantime' => 'Sperrdauer', + 'attr_maxretry' => 'Max. Fehlversuche', + 'attr_findtime' => 'Zeitfenster', + 'notify_saved' => 'fail2ban konfiguriert.', + ], + + // ── File editor ─────────────────────────────────────────────────────── + 'file_editor' => [ + 'too_big' => 'Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.', + 'binary' => 'Binärdatei — kann nicht im Editor angezeigt werden.', + 'notify_saved' => '„:name“ gespeichert.', + ], + + // ── Firewall rule ───────────────────────────────────────────────────── + 'firewall_rule' => [ + 'title' => 'Firewall-Regel hinzufügen', + 'subtitle_firewalld' => 'Öffnet einen Port in der Standard-Zone von firewalld.', + 'subtitle_ufw' => 'Neue UFW-Regel. Quelle leer lassen = von überall.', + 'action_label' => 'Aktion', + 'action_allow' => 'Erlauben (allow)', + 'action_deny' => 'Verwerfen (deny)', + 'action_reject' => 'Ablehnen (reject)', + 'action_limit' => 'Begrenzen (limit)', + 'port_label' => 'Port', + 'port_optional' => '(optional)', + 'port_placeholder' => 'z. B. 8080', + 'proto_label' => 'Protokoll', + 'proto_both' => 'Beide', + 'source_label' => 'Quelle (optional)', + 'source_placeholder' => 'IP oder CIDR, z. B. 10.0.0.0/8', + 'error_add_failed' => 'Regel konnte nicht hinzugefügt werden.', + 'summary_port_opened' => 'Port :port/:proto geöffnet', + 'summary_from' => 'von :source', + 'notify_added' => 'Firewall-Regel hinzugefügt.', + ], + + // ── Hardening action ────────────────────────────────────────────────── + 'hardening_action' => [ + 'title_fallback' => 'Härtung anwenden', + 'result_ok' => 'Erfolgreich angewendet.', + 'result_failed' => 'Fehlgeschlagen.', + 'error_unknown' => 'Unbekannter Fehler.', + 'audit_failed_suffix' => '(fehlgeschlagen)', + 'notify_applied' => ':action angewendet.', + 'notify_failed' => ':action fehlgeschlagen.', + ], + + // ── System update ───────────────────────────────────────────────────── + 'system_update' => [ + 'title' => 'System-Updates', + 'subtitle' => 'Aktualisiert die installierten Pakete auf :server über die Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt.', + 'this_server' => 'diesem Server', + 'unsupported_fallback' => 'Auf diesem System nicht verfügbar.', + 'count_unknown' => 'Update-Anzahl unbekannt', + 'count_one' => '1 Paket-Update verfügbar', + 'count_many' => ':count Paket-Updates verfügbar', + 'hint_unknown' => 'Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich.', + 'hint_recommended' => 'Aktualisierung empfohlen.', + 'hint_up_to_date' => 'Das System ist auf dem aktuellen Stand.', + 'result_ok' => 'System aktualisiert.', + 'result_failed' => 'Aktualisierung fehlgeschlagen.', + 'submit' => 'Jetzt aktualisieren', + 'notify_updated' => 'System aktualisiert.', + 'notify_failed' => 'Aktualisierung fehlgeschlagen.', + ], + + // ── Generic confirm dialog defaults (ConfirmAction) ─────────────────── + 'confirm_action' => [ + 'default_heading' => 'Aktion bestätigen', + 'default_notify' => 'Aktion ausgeführt.', + ], +]; diff --git a/lang/de/servers.php b/lang/de/servers.php new file mode 100644 index 0000000..863bfb1 --- /dev/null +++ b/lang/de/servers.php @@ -0,0 +1,174 @@ +<?php + +// Servers area: fleet index, server detail, switcher (R16). German = source of truth. +return [ + // ── Page titles ─────────────────────────────────────────────────────── + 'index_title' => 'Server — Clusev', + 'show_title' => 'Server-Details — Clusev', + + // ── Status labels (status pills / KPIs) ─────────────────────────────── + 'status_online' => 'Online', + 'status_warning' => 'Warnung', + 'status_offline' => 'Offline', + + // ── Index: header ───────────────────────────────────────────────────── + 'fleet_eyebrow' => 'Flotte', + 'heading' => 'Server', + 'add_server' => 'Server hinzufügen', + 'online_of_total' => ':online / :total online', + + // ── Index: KPI grid ─────────────────────────────────────────────────── + 'kpi_total' => 'Gesamt', + 'kpi_online' => 'Online', + 'kpi_warning' => 'Warnung', + 'kpi_offline' => 'Offline', + + // ── Index: fleet panel ──────────────────────────────────────────────── + 'fleet_title' => 'Flotte', + 'in_cluster' => ':count im Cluster', + 'search_label' => 'Server suchen', + 'search_placeholder' => 'Name oder IP…', + 'empty_title' => 'Keine Server gefunden.', + 'empty_hint' => 'Suchbegriff anpassen oder Filter zurücksetzen.', + + // ── Show: back link ─────────────────────────────────────────────────── + 'back_to_fleet' => 'Zurück zur Flotte', + + // ── Show: hero header ───────────────────────────────────────────────── + 'uptime' => 'Uptime :value', + 'last_seen' => 'zuletzt :value', + 'system_updates' => 'System-Updates', + 'files' => 'Dateien', + + // ── Show: offline banner ────────────────────────────────────────────── + 'offline_banner' => 'Keine Live-Verbindung — gezeigt werden die zuletzt gespeicherten Werte. Zugang/Erreichbarkeit prüfen.', + + // ── Show: SSH access (credential) ───────────────────────────────────── + 'ssh_access_title' => 'SSH-Zugang', + 'ssh_access_subtitle' => 'Hinterlegte Anmeldung für die Live-Steuerung', + 'cred_locked' => 'Gesperrt', + 'cred_active' => 'Aktiv', + 'cred_auth_key' => 'SSH-Key', + 'cred_auth_password' => 'Passwort', + 'cred_none_title' => 'Kein Zugang hinterlegt', + 'cred_none_hint' => 'Ohne SSH-Zugang ist keine Live-Steuerung möglich.', + 'cred_deposit' => 'Zugang hinterlegen', + + // ── Show: specifications ────────────────────────────────────────────── + 'specs_title' => 'Spezifikationen', + 'specs_subtitle' => 'Hardware-Profil', + 'spec_arch' => 'Architektur', + 'spec_kernel' => 'Kernel', + 'spec_virt' => 'Virtualisierung', + 'spec_cores' => 'Kerne', + 'spec_ram' => 'RAM', + 'spec_disk' => 'Disk', + 'spec_system' => 'System', + 'spec_package_manager' => 'Paketverwaltung', + 'spec_firewall' => 'Firewall', + 'gb' => ':value GB', + + // ── Show: vitals ────────────────────────────────────────────────────── + 'vital_cpu' => 'CPU', + 'vital_ram' => 'RAM', + 'vital_disk' => 'Disk', + 'load' => 'Last :value', + 'cores_count' => ':count Kerne', + 'ram_used_of_total' => ':used / :total GB', + 'disk_used_of_total' => ':used / :total GB', + + // ── Show: security / hardening ──────────────────────────────────────── + 'security_title' => 'Sicherheit', + 'security_subtitle' => 'Hardening-Checkliste', + 'check_secure' => 'sicher', + 'check_open' => 'offen', + 'fail2ban_configure' => 'fail2ban konfigurieren', + + // ── Show: firewall rules ────────────────────────────────────────────── + 'firewall_title' => 'Firewall-Regeln', + 'firewall_unavailable' => 'Nicht verfügbar', + 'firewall_sub_active' => ' · aktiv', + 'firewall_sub_inactive' => ' · inaktiv', + 'firewall_sub_not_installed' => ' · nicht installiert', + 'firewall_add_rule' => 'Regel', + 'firewall_not_supported' => 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.', + 'firewall_read_error' => 'Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.', + 'firewall_tool_not_installed' => ':tool ist nicht installiert.', + 'firewalld_inactive' => 'firewalld ist installiert, aber inaktiv.', + 'firewall_default' => 'Standard', + 'firewall_incoming' => 'eingehend: :value', + 'firewall_outgoing' => 'ausgehend: :value', + 'firewall_zone' => 'Zone', + 'firewall_readonly' => 'firewalld: nur Anzeige — Regeln bitte direkt am Server verwalten.', + 'firewall_rule_from' => 'von :value', + 'firewall_remove_rule' => 'Regel entfernen', + 'firewall_no_rules' => 'Keine Regeln definiert.', + + // ── Show: fail2ban status ───────────────────────────────────────────── + 'fail2ban_title' => 'fail2ban-Status', + 'fail2ban_unavailable' => 'Nicht verfügbar', + 'fail2ban_sub_active' => 'aktiv · :count gesperrt', + 'fail2ban_sub_installed_inactive' => 'installiert · inaktiv', + 'fail2ban_sub_not_installed' => 'nicht installiert', + 'fail2ban_ban_ip' => 'IP sperren', + 'fail2ban_not_supported' => 'fail2ban ist auf diesem System nicht verfügbar.', + 'fail2ban_read_error' => 'fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.', + 'fail2ban_state_installed_inactive' => 'fail2ban ist installiert, aber inaktiv.', + 'fail2ban_state_not_installed' => 'fail2ban ist nicht installiert.', + 'fail2ban_banned' => 'gebannt: :count', + 'fail2ban_failed' => 'Fehlversuche: :count', + 'fail2ban_no_banned' => 'Keine gebannten IPs.', + 'whitelist_label' => 'Whitelist (ignoreip)', + 'whitelist_placeholder' => 'IP oder CIDR', + + // ── Show: volumes ───────────────────────────────────────────────────── + 'volumes_title' => 'Volumes', + 'mountpoints' => ':count Mountpoints', + 'no_volumes' => 'Keine Volumes gelesen.', + + // ── Show: network interfaces ────────────────────────────────────────── + 'interfaces_title' => 'Netzwerk-Interfaces', + 'interfaces_count' => ':count Schnittstellen', + 'col_name' => 'Name', + 'col_ip' => 'IP', + 'col_mac' => 'MAC', + 'col_rx' => 'RX', + 'col_tx' => 'TX', + + // ── Show: SSH keys ──────────────────────────────────────────────────── + 'ssh_keys_title' => 'SSH-Schlüssel', + 'ssh_keys_authorized' => ':count autorisiert', + 'ssh_keys_add' => 'Schlüssel hinzufügen', + 'ssh_keys_remove' => 'Schlüssel entfernen', + 'ssh_keys_none' => 'Keine autorisierten Schlüssel.', + + // ── Show: loading skeletons ─────────────────────────────────────────── + 'loading' => 'lädt…', + + // ── Switcher ────────────────────────────────────────────────────────── + 'switcher_no_server' => 'Kein Server', + + // ── Notifications (Show component) ──────────────────────────────────── + 'notify_access_locked' => 'Zugang gesperrt.', + 'notify_access_unlocked' => 'Zugang entsperrt.', + 'notify_access_deleted' => 'Zugang gelöscht.', + 'notify_remove_failed' => 'Entfernen fehlgeschlagen: :error', + 'notify_key_removed' => 'Schlüssel „:name“ entfernt.', + 'notify_rule_not_found' => 'Regel existiert nicht mehr.', + 'notify_firewall_rule_removed' => 'Firewall-Regel entfernt.', + 'notify_unban_failed' => 'Entsperren fehlgeschlagen: :error', + 'notify_ip_unbanned' => 'IP :ip entsperrt.', + 'notify_whitelist_error' => 'Whitelist: :error', + 'notify_ip_whitelisted' => ':ip zur Whitelist hinzugefügt.', + 'notify_ip_unwhitelisted' => ':ip von der Whitelist entfernt.', + 'error_unknown' => 'unbekannt', + 'error_failed' => 'fehlgeschlagen', + + // ── Confirm modals (Show component) ─────────────────────────────────── + 'confirm_remove_key_heading' => 'SSH-Schlüssel entfernen', + 'confirm_remove_key_body' => 'Der Schlüssel „:name“ verliert den Zugang zu :server.', + 'confirm_delete_credential_heading' => 'Zugang löschen', + 'confirm_delete_credential_body' => 'Der hinterlegte SSH-Zugang für :server wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.', + 'confirm_remove_rule_heading' => 'Firewall-Regel entfernen', + 'confirm_remove_rule_body' => 'Die Regel „:label“ wird aus der Firewall von :server entfernt.', +]; diff --git a/lang/de/services.php b/lang/de/services.php new file mode 100644 index 0000000..14d94fb --- /dev/null +++ b/lang/de/services.php @@ -0,0 +1,57 @@ +<?php + +// Services (systemd) page strings (R16). Shared buttons live in common.php. +return [ + // Header / overview + 'eyebrow' => 'Dienste', + 'heading' => 'Dienste', + 'fleet_active' => ':active / :total aktiv', + + // systemd panel + 'panel_title' => 'systemd-Dienste', + 'panel_subtitle' => ':total Units · :server', + 'search_label' => 'Dienste durchsuchen', + 'search_placeholder' => 'Dienst suchen…', + + // Service status labels (status triad) + 'status_online' => 'aktiv', + 'status_warning' => 'degradiert', + 'status_offline' => 'gestoppt', + + // Service actions + 'start' => 'Starten', + 'stop' => 'Stopp', + 'restart' => 'Neustart', + + // Empty state + 'empty_title' => 'Kein Dienst gefunden', + 'empty_hint' => 'Suche „:search“ ergab keine Treffer.', + + // Journal panel + 'journal_title' => 'Journal', + 'journal_subtitle' => 'journalctl -f · :server', + 'journal_live' => 'live', + 'journal_follow' => 'Folge Journal · :count Zeilen', + + // Confirm modal — operations + 'confirm_start_heading' => 'Dienst starten', + 'confirm_start_phrase' => 'wird gestartet', + 'confirm_start_label' => 'Starten', + 'confirm_stop_heading' => 'Dienst stoppen', + 'confirm_stop_phrase' => 'wird gestoppt', + 'confirm_stop_label' => 'Stoppen', + 'confirm_restart_heading' => 'Dienst neu starten', + 'confirm_restart_phrase' => 'wird neu gestartet', + 'confirm_restart_label' => 'Neu starten', + 'confirm_body' => ':name :phrase. Ausgeführt über systemctl auf :server.', + 'confirm_notify' => ':name: :label ausgeführt.', + + // Action results / notifications + 'action_error' => ':name: :error', + 'action_done' => ':name: :op ausgeführt.', + 'action_failed' => ':name: fehlgeschlagen — :output', + 'no_permission' => 'keine Rechte', + + // Page title + 'title' => 'Dienste — Clusev', +]; diff --git a/lang/de/settings.php b/lang/de/settings.php new file mode 100644 index 0000000..234b60e --- /dev/null +++ b/lang/de/settings.php @@ -0,0 +1,49 @@ +<?php + +// Settings (profile + security) page strings (R16). Shared buttons live in common.php. +return [ + // Identity header + 'account' => 'Konto', + 'role_admin' => 'Administrator', + 'two_factor_on' => '2FA aktiv', + 'two_factor_off' => '2FA aus', + + // Section nav tabs + 'tab_profile' => 'Profil', + 'tab_security' => 'Sicherheit', + + // Profile panel + 'profile_title' => 'Profil', + 'profile_subtitle' => 'Name und E-Mail', + 'name' => 'Name', + 'email' => 'E-Mail', + + // Password panel + 'password_title' => 'Passwort', + 'password_subtitle' => 'Mindestens 10 Zeichen', + 'current_password' => 'Aktuelles Passwort', + 'new_password' => 'Neues Passwort', + 'repeat_password' => 'Wiederholen', + 'change_password' => 'Passwort ändern', + + // Two-factor panel + 'twofa_title' => 'Zwei-Faktor-Authentifizierung', + 'twofa_subtitle' => 'TOTP (Authenticator-App)', + 'twofa_status_on' => '2FA ist aktiv', + 'twofa_status_off' => '2FA ist nicht eingerichtet', + 'twofa_hint_on' => 'Der Login erfordert einen TOTP-Code.', + 'twofa_hint_off' => 'Empfohlen — schützt den Login mit einem zweiten Faktor.', + 'twofa_setup' => 'Einrichten', + + // Notifications + 'profile_saved' => 'Profil gespeichert.', + 'password_changed' => 'Passwort geändert.', + + // Disable-2FA confirmation modal + 'disable_2fa_heading' => '2FA deaktivieren', + 'disable_2fa_body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.', + 'disable_2fa_notify' => '2FA deaktiviert.', + + // Page title + 'title' => 'Einstellungen — Clusev', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php new file mode 100644 index 0000000..70798f4 --- /dev/null +++ b/lang/de/shell.php @@ -0,0 +1,49 @@ +<?php + +// App shell: sidebar nav, topbar, command palette, layout chrome (R16). +return [ + // Sidebar — accessibility / brand + 'nav_aria' => 'Seitennavigation', + 'menu_close' => 'Menü schließen', + 'menu_open' => 'Menü öffnen', + 'logout' => 'Abmelden', + + // Sidebar — nav group labels + 'group_fleet' => 'Flotte', + 'group_account' => 'Konto', + + // Sidebar — nav items + 'nav_dashboard' => 'Dashboard', + 'nav_servers' => 'Server', + 'nav_services' => 'Dienste', + 'nav_files' => 'Dateien', + 'nav_audit' => 'Audit-Log', + 'nav_settings' => 'Einstellungen', + 'nav_system' => 'System', + 'nav_versions' => 'Version', + + // Sidebar — user / 2FA badge + 'twofa_on' => '2FA aktiv', + 'twofa_off' => '2FA aus', + + // Topbar + 'title_default' => 'Übersicht', + 'commands_open_aria' => 'Befehle öffnen (Strg K)', + 'commands_title' => 'Befehle (Strg K)', + 'shortcut_ctrl_k' => 'Strg K', + 'fleet_online' => ':online / :total online', + + // Command palette + 'cmdk_open' => 'Befehle öffnen', + 'cmdk_focus_search' => 'Suche auf der Seite fokussieren', + 'cmdk_show_help' => 'Diese Hilfe anzeigen', + 'cmdk_placeholder' => 'Befehl oder Seite…', + 'cmdk_no_match' => 'Kein Treffer.', + 'cmdk_open_hint' => 'öffnen', + 'cmdk_shortcuts_hint' => '? Tastenkürzel', + 'cmdk_esc' => 'Esc', + 'shortcuts_heading' => 'Tastenkürzel', + + // Toaster fallback + 'toast_done' => 'Erledigt', +]; diff --git a/lang/de/system.php b/lang/de/system.php new file mode 100644 index 0000000..d4d2f4d --- /dev/null +++ b/lang/de/system.php @@ -0,0 +1,47 @@ +<?php + +// System settings (Domain/TLS + release channel) page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'System', + 'heading' => 'Domain, TLS & Release-Kanal', + 'subheading' => 'Zugriffsadresse des Panels und Update-Quelle', + 'https_active' => 'HTTPS aktiv', + 'https_plain' => 'Klartext-HTTP', + + // Domain & TLS panel + 'tls_title' => 'Domain & TLS', + 'tls_subtitle' => 'Erreichbarkeit des Panels und Let\'s-Encrypt-Zertifikat', + 'tls_active_title' => 'TLS aktiv — Zertifikat automatisch über Let\'s Encrypt', + 'tls_active_body_pre' => 'Erreichbar unter', + 'tls_active_body_post' => 'wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.', + 'tls_bare_title' => 'Bare-IP-Modus: Klartext-HTTP', + 'tls_bare_body' => 'Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen — TLS wird dann automatisch eingerichtet, ohne weitere Schritte.', + + // Read-only domain facts + 'domain' => 'Domain', + 'domain_via_ip' => '— (Zugriff per IP)', + 'access_address' => 'Zugriffsadresse', + 'access_bare_ip' => 'HTTP (Bare-IP)', + 'domain_note_pre' => 'Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und Cookie-Sicherheit konsistent. Zum Ändern', + 'domain_note_post' => 'mit der neuen Domain erneut ausführen — TLS richtet sich danach automatisch ein.', + + // Release channel panel + 'channel_title' => 'Release-Kanal', + 'channel_subtitle' => 'Quelle für Updates', + 'channel_radiogroup_label' => 'Release-Kanal', + 'channel_current' => 'Kanal:', + + // Channel descriptions (CHANNELS array) + 'channel_stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.', + 'channel_beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.', + + // Change-channel confirmation modal + 'change_channel_heading' => 'Release-Kanal wechseln', + 'change_channel_body' => 'Der Release-Kanal wird auf „:channel“ gesetzt. Updates werden dann aus dieser Quelle bezogen.', + 'change_channel_confirm' => 'Wechseln', + 'change_channel_notify' => 'Release-Kanal auf „:channel“ gesetzt.', + + // Page title + 'title' => 'System — Clusev', +]; diff --git a/lang/de/versions.php b/lang/de/versions.php new file mode 100644 index 0000000..cdf5bb2 --- /dev/null +++ b/lang/de/versions.php @@ -0,0 +1,47 @@ +<?php + +// Version & releases page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'Version', + 'heading' => 'Version & Releases', + 'check_updates' => 'Nach Updates suchen', + + // Status banner + 'channel_badge' => 'Kanal: :channel', + 'latest_release_badge' => 'neuestes Release v:tag', + 'banner_default' => 'Installierte Version. „Nach Updates suchen“ vergleicht sie ehrlich mit dem neuesten getaggten Release im Kanal. Aktualisierung läuft über den Deploy (Image ziehen + migrieren).', + 'checked_at' => 'geprüft :time', + + // Update-check results (notifications / status line) + 'status_none_tagged' => 'Noch kein Release getaggt — installierte Version v:installed (Kanal: :channel).', + 'status_update_available' => 'Update verfügbar: v:latest (Kanal: :channel).', + 'status_current' => 'Aktuell — v:installed (Kanal: :channel).', + + // Changelog panel + 'changelog_title' => 'Änderungsprotokoll', + 'changelog_subtitle' => ':count Versionen', + 'unreleased' => 'Unreleased', + 'in_progress' => 'in Arbeit', + 'changelog_empty' => 'Kein Änderungsprotokoll gefunden.', + + // Installed panel + 'installed_title' => 'Installiert', + 'running' => 'läuft', + 'build' => 'Build', + 'branch' => 'Branch', + 'channel' => 'Kanal', + 'latest_release' => 'Neuestes Release', + 'license' => 'Lizenz', + + // Update panel + 'update_title' => 'Aktualisierung', + 'update_hint' => 'Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:', + + // Project panel + 'project_title' => 'Projekt', + 'open_core' => 'Open Core · :license', + + // Page title + 'title' => 'Version — Clusev', +]; diff --git a/lang/en/audit.php b/lang/en/audit.php new file mode 100644 index 0000000..87b9735 --- /dev/null +++ b/lang/en/audit.php @@ -0,0 +1,23 @@ +<?php + +// Audit log page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'Security', + 'heading' => 'Audit log', + 'event_count' => ':count events', + + // Events panel + 'panel_title' => 'Events', + 'panel_subtitle' => 'last 50 · newest first', + 'search_label' => 'Search audit log', + 'search_placeholder' => 'Actor, action, target …', + + // Empty state + 'empty_title' => 'No events', + 'empty_filtered' => 'No audit entries found for “:query”.', + 'empty_none' => 'There are no audit entries yet.', + + // Page title + 'title' => 'Audit log — Clusev', +]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..27d7b8b --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,74 @@ +<?php + +// Auth feature strings (login, password change, 2FA challenge + setup, auth brand panel). +// Shared actions/status live in common.php (R16). en = natural English, same terse register. +return [ + // ── Login ──────────────────────────────────────────────────────────── + 'control_plane' => 'Control Plane', + 'login_heading' => 'Sign in', + 'login_subtitle' => 'Access to the Clusev panel.', + 'email' => 'Email', + 'password' => 'Password', + 'remember_me' => 'Stay signed in', + 'login_submit' => 'Sign in', + 'checking' => 'Checking…', + 'secured' => 'Secured', + 'security_note' => 'Connection over SSH · 2FA required · every action lands in the audit log.', + + // ── Password change ────────────────────────────────────────────────── + 'security' => 'Security', + 'password_change_heading' => 'Change password', + 'password_change_subtitle' => 'Set your own password (min. 12 characters, upper/lowercase + a digit).', + 'current_password' => 'Current password', + 'new_password' => 'New password', + 'confirm_new_password' => 'Confirm new password', + 'save_and_continue' => 'Save & continue', + 'saving' => 'Saving…', + + // ── Two-factor (shared label) ──────────────────────────────────────── + 'two_factor' => 'Two-factor', + + // ── 2FA challenge ──────────────────────────────────────────────────── + 'challenge_heading' => 'Verification', + 'challenge_subtitle' => '6-digit code from your authenticator app.', + 'code' => 'Code', + 'back_to_login' => 'Back to sign in', + + // ── 2FA setup ──────────────────────────────────────────────────────── + 'setup_heading' => 'Set up', + 'setup_subtitle' => 'Scan the code with an authenticator app (Aegis, Google Authenticator, …) and confirm with a code.', + 'qr_alt' => '2FA QR code', + 'secret_manual' => 'Secret (manual)', + 'confirmation_code' => 'Confirmation code', + 'activate' => 'Activate', + + // ── Brand panel (auth layout) ──────────────────────────────────────── + 'brand_headline_lead' => 'One console for your', + 'brand_headline_accent' => 'entire fleet', + 'brand_tagline' => 'Agentless over SSH. Metrics, services, files and a gapless audit log — securely controlled from one panel.', + 'terminal_title' => 'clusev · security model', + 'terminal_ssh' => 'agentless · exec + SFTP over phpseclib', + 'terminal_key' => 'host keys pinned via TOFU', + 'terminal_2fa' => 'enforced on every login', + 'terminal_log' => 'every action in the audit log', + 'terminal_core' => 'Open Core · AGPL-licensed', + 'footer_copyright' => '© :year Clusev · Fleet Control', + 'footer_operational' => 'operational', + + // ── Page titles ────────────────────────────────────────────────────── + 'title_login' => 'Sign in — Clusev', + 'title_password_change' => 'Change password — Clusev', + 'title_challenge' => 'Verification — Clusev', + 'title_setup' => 'Set up 2FA — Clusev', + + // ── Validation / error messages ────────────────────────────────────── + 'too_many_attempts' => 'Too many attempts. Please try again in :seconds seconds.', + 'invalid_credentials' => 'These credentials do not match.', + 'session_expired' => 'Session expired. Please sign in again.', + 'invalid_code' => 'Invalid code.', + 'code_mismatch' => 'Code does not match — check your authenticator app’s clock.', + + // ── Validation attribute names ─────────────────────────────────────── + 'attr_current_password' => 'current password', + 'attr_new_password' => 'new password', +]; diff --git a/lang/en/common.php b/lang/en/common.php new file mode 100644 index 0000000..28e2a97 --- /dev/null +++ b/lang/en/common.php @@ -0,0 +1,43 @@ +<?php + +// Shared UI strings reused across the app (R16). Feature-specific strings live in +// their own group file (lang/<code>/<group>.php). Keep this list small and generic. +return [ + // Actions + 'save' => 'Save', + 'cancel' => 'Cancel', + 'close' => 'Close', + 'delete' => 'Delete', + 'remove' => 'Remove', + 'add' => 'Add', + 'edit' => 'Edit', + 'enable' => 'Enable', + 'disable' => 'Disable', + 'lock' => 'Lock', + 'unlock' => 'Unblock', + 'back' => 'Back', + 'retry' => 'Try again', + 'confirm' => 'Confirm', + 'open' => 'Open', + 'more' => 'more', + 'apply' => 'Apply', + + // Status / state + 'online' => 'Online', + 'offline' => 'Offline', + 'warning' => 'Warning', + 'active' => 'active', + 'inactive' => 'inactive', + 'secure' => 'secure', + 'unsupported' => 'n/a', + 'unknown' => 'unknown', + 'loading' => 'Loading…', + 'none' => '—', + + // Generic + 'search' => 'Search', + 'actions' => 'Actions', + 'server_not_found' => 'Server not found.', + 'failed' => 'Failed.', + 'done' => 'Done.', +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php new file mode 100644 index 0000000..bbedd01 --- /dev/null +++ b/lang/en/dashboard.php @@ -0,0 +1,33 @@ +<?php + +// Dashboard / live metrics view (R16). English translation. +return [ + // Page + 'page_title' => 'Clusev — Dashboard', + 'heading' => 'Overview', + + // Service state labels (systemd ActiveState, operator wording) + 'svc_online' => 'active', + 'svc_warning' => 'degraded', + 'svc_offline' => 'stopped', + + // Utilisation chart + 'utilization' => 'Utilization', + 'utilization_subtitle' => 'CPU & Memory · 15 min', + 'axis_minus_15min' => '-15 min', + 'axis_now' => 'now', + + // systemd services panel + 'services_title' => 'systemd services', + 'col_state' => 'State', + 'no_service_data' => 'No service data — server not connected.', + + // Audit panel + 'audit_title' => 'Audit log', + 'audit_subtitle' => 'recent events', + 'no_events' => 'No events.', + + // Cores (CPU) + 'core' => 'core', + 'cores' => 'cores', +]; diff --git a/lang/en/files.php b/lang/en/files.php new file mode 100644 index 0000000..5022668 --- /dev/null +++ b/lang/en/files.php @@ -0,0 +1,36 @@ +<?php + +// File manager (SFTP) page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'Files', + 'heading' => 'Files', + 'sftp_connected' => 'SFTP connected', + 'sftp_disconnected' => 'not connected', + + // Listing panel + 'panel_subtitle' => ':dirs folders · :files files', + 'upload' => 'Upload', + + // Column headers + 'col_name' => 'Name', + 'col_perms' => 'Permissions', + 'col_size' => 'Size', + 'col_modified' => 'Modified', + + // Row actions + 'download' => 'Download', + + // Upload / notifications + 'uploaded' => '“:name” uploaded.', + 'upload_failed' => 'Upload failed: :error', + + // Delete (confirm modal) + 'delete_heading' => 'Delete file', + 'delete_body' => '“:name” will be permanently removed from :path.', + 'delete_notify' => '“:name” deleted.', + 'delete_failed' => 'Delete failed: :error', + + // Page title + 'title' => 'Files — Clusev', +]; diff --git a/lang/en/modals.php b/lang/en/modals.php new file mode 100644 index 0000000..09a0a70 --- /dev/null +++ b/lang/en/modals.php @@ -0,0 +1,169 @@ +<?php + +// Modal dialogs (wire-elements/modal). English translation of lang/de/modals.php. +// Same keys in both files (R16). Shared strings come from common.*. +return [ + // ── Add SSH key ─────────────────────────────────────────────────────── + 'add_ssh_key' => [ + 'title' => 'Add SSH key', + 'subtitle_before' => 'Paste a public key — it is appended to', + 'subtitle_after' => '.', + 'public_key_label' => 'Public key', + 'generate' => 'Generate new pair', + 'generating' => 'generating…', + 'public_key_placeholder' => 'ssh-ed25519 AAAA… comment — or generate above', + 'private_key_warning' => 'Private key — shown only now, store it securely.', + 'notify_added' => 'SSH key added.', + ], + + // ── Create server ───────────────────────────────────────────────────── + 'create_server' => [ + 'title' => 'Add server', + 'subtitle' => 'Add a new host to the fleet and store its SSH access in the encrypted vault.', + 'name_label' => 'Name', + 'name_placeholder' => 'e.g. Prod-Web-01', + 'ip_host_label' => 'IP/Host', + 'ip_host_placeholder' => '10.10.90.10 or host.example.com', + 'ssh_port_label' => 'SSH port', + 'user_label' => 'User', + 'auth_label' => 'Authentication', + 'auth_password' => 'Password', + 'auth_key' => 'Private key', + 'secret_key_label' => 'Private key (PEM)', + 'secret_password_label' => 'Password', + 'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----', + 'passphrase_label' => 'Passphrase (optional)', + 'passphrase_placeholder' => 'empty = none', + 'credential_name_label' => 'Access name (optional)', + 'credential_name_placeholder' => 'e.g. Root login · Deploy user', + 'validation_ip_or_host' => 'Please enter a valid IP address or hostname.', + 'attr_name' => 'Name', + 'attr_ip' => 'IP/Host', + 'attr_ssh_port' => 'SSH port', + 'attr_user' => 'User', + 'attr_auth' => 'Authentication', + 'attr_secret_key' => 'Private key', + 'attr_secret_password' => 'Password', + 'attr_credential_name' => 'Access name', + 'notify_added' => 'Server added.', + ], + + // ── Edit credential ─────────────────────────────────────────────────── + 'edit_credential' => [ + 'title' => 'SSH access', + 'subtitle_before' => 'Store the access here — e.g.', + 'subtitle_after' => 'for full control (systemctl, journal).', + 'name_label' => 'Name (optional)', + 'name_placeholder' => 'e.g. Prod-Server X · Backup user', + 'user_label' => 'User', + 'auth_label' => 'Authentication', + 'auth_password' => 'Password', + 'auth_key' => 'Private key', + 'secret_key_label' => 'Private key (PEM)', + 'secret_password_label' => 'Password', + 'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----', + 'passphrase_label' => 'Passphrase (optional)', + 'passphrase_placeholder' => 'empty = none', + 'error_required' => 'User and password/key are required.', + 'error_name_too_long' => 'Name may be at most 120 characters long.', + 'notify_saved' => 'Credentials saved.', + ], + + // ── fail2ban: ban IP ────────────────────────────────────────────────── + 'fail2ban_ban' => [ + 'title' => 'Ban IP manually', + 'subtitle' => 'Bans an IP address immediately in the selected jail. Loopback and the Clusev access cannot be banned.', + 'jail_label' => 'Jail', + 'ip_label' => 'IP address', + 'ip_placeholder' => 'e.g. 203.0.113.10', + 'submit' => 'Ban IP', + 'error_ban_failed' => 'Ban failed.', + 'notify_banned' => 'IP :ip banned.', + ], + + // ── fail2ban: config ────────────────────────────────────────────────── + 'fail2ban_config' => [ + 'title' => 'Configure fail2ban', + 'subtitle' => 'Defines when fail2ban bans an IP on :server and for how long. Repeated failed logins within the time window lead to a ban.', + 'this_server' => 'this server', + 'bantime_label' => 'Ban duration', + 'bantime_placeholder' => 'e.g. 600, 10m, 1h, -1 = permanent', + 'maxretry_label' => 'Max. retries', + 'findtime_label' => 'Time window', + 'findtime_placeholder' => 'e.g. 600, 10m, 1h', + 'error_read_locked' => 'Current configuration could not be read — saving is blocked.', + 'error_bantime_invalid' => 'Invalid duration (e.g. 600, 10m, 1h, -1 for permanent).', + 'error_findtime_invalid' => 'Invalid duration (e.g. 600, 10m, 1h).', + 'error_save_failed' => 'Save failed. :output', + 'attr_bantime' => 'Ban duration', + 'attr_maxretry' => 'Max. retries', + 'attr_findtime' => 'Time window', + 'notify_saved' => 'fail2ban configured.', + ], + + // ── File editor ─────────────────────────────────────────────────────── + 'file_editor' => [ + 'too_big' => 'File too large to edit (over 256 KB) — please download it.', + 'binary' => 'Binary file — cannot be shown in the editor.', + 'notify_saved' => '“:name” saved.', + ], + + // ── Firewall rule ───────────────────────────────────────────────────── + 'firewall_rule' => [ + 'title' => 'Add firewall rule', + 'subtitle_firewalld' => 'Opens a port in firewalld’s default zone.', + 'subtitle_ufw' => 'New UFW rule. Leave source empty = from anywhere.', + 'action_label' => 'Action', + 'action_allow' => 'Allow (allow)', + 'action_deny' => 'Drop (deny)', + 'action_reject' => 'Reject (reject)', + 'action_limit' => 'Limit (limit)', + 'port_label' => 'Port', + 'port_optional' => '(optional)', + 'port_placeholder' => 'e.g. 8080', + 'proto_label' => 'Protocol', + 'proto_both' => 'Both', + 'source_label' => 'Source (optional)', + 'source_placeholder' => 'IP or CIDR, e.g. 10.0.0.0/8', + 'error_add_failed' => 'Rule could not be added.', + 'summary_port_opened' => 'Port :port/:proto opened', + 'summary_from' => 'from :source', + 'notify_added' => 'Firewall rule added.', + ], + + // ── Hardening action ────────────────────────────────────────────────── + 'hardening_action' => [ + 'title_fallback' => 'Apply hardening', + 'result_ok' => 'Applied successfully.', + 'result_failed' => 'Failed.', + 'error_unknown' => 'Unknown error.', + 'audit_failed_suffix' => '(failed)', + 'notify_applied' => ':action applied.', + 'notify_failed' => ':action failed.', + ], + + // ── System update ───────────────────────────────────────────────────── + 'system_update' => [ + 'title' => 'System updates', + 'subtitle' => 'Updates the installed packages on :server via the package manager. No new version jumps are forced — only available updates are applied.', + 'this_server' => 'this server', + 'unsupported_fallback' => 'Not available on this system.', + 'count_unknown' => 'Update count unknown', + 'count_one' => '1 package update available', + 'count_many' => ':count package updates available', + 'hint_unknown' => 'Could not be determined — updating is still possible.', + 'hint_recommended' => 'Updating recommended.', + 'hint_up_to_date' => 'The system is up to date.', + 'result_ok' => 'System updated.', + 'result_failed' => 'Update failed.', + 'submit' => 'Update now', + 'notify_updated' => 'System updated.', + 'notify_failed' => 'Update failed.', + ], + + // ── Generic confirm dialog defaults (ConfirmAction) ─────────────────── + 'confirm_action' => [ + 'default_heading' => 'Confirm action', + 'default_notify' => 'Action carried out.', + ], +]; diff --git a/lang/en/servers.php b/lang/en/servers.php new file mode 100644 index 0000000..844bf77 --- /dev/null +++ b/lang/en/servers.php @@ -0,0 +1,174 @@ +<?php + +// Servers area: fleet index, server detail, switcher (R16). English translation. +return [ + // ── Page titles ─────────────────────────────────────────────────────── + 'index_title' => 'Servers — Clusev', + 'show_title' => 'Server details — Clusev', + + // ── Status labels (status pills / KPIs) ─────────────────────────────── + 'status_online' => 'Online', + 'status_warning' => 'Warning', + 'status_offline' => 'Offline', + + // ── Index: header ───────────────────────────────────────────────────── + 'fleet_eyebrow' => 'Fleet', + 'heading' => 'Servers', + 'add_server' => 'Add server', + 'online_of_total' => ':online / :total online', + + // ── Index: KPI grid ─────────────────────────────────────────────────── + 'kpi_total' => 'Total', + 'kpi_online' => 'Online', + 'kpi_warning' => 'Warning', + 'kpi_offline' => 'Offline', + + // ── Index: fleet panel ──────────────────────────────────────────────── + 'fleet_title' => 'Fleet', + 'in_cluster' => ':count in cluster', + 'search_label' => 'Search servers', + 'search_placeholder' => 'Name or IP…', + 'empty_title' => 'No servers found.', + 'empty_hint' => 'Adjust the search term or reset the filter.', + + // ── Show: back link ─────────────────────────────────────────────────── + 'back_to_fleet' => 'Back to fleet', + + // ── Show: hero header ───────────────────────────────────────────────── + 'uptime' => 'Uptime :value', + 'last_seen' => 'last :value', + 'system_updates' => 'System updates', + 'files' => 'Files', + + // ── Show: offline banner ────────────────────────────────────────────── + 'offline_banner' => 'No live connection — showing the last stored values. Check access/reachability.', + + // ── Show: SSH access (credential) ───────────────────────────────────── + 'ssh_access_title' => 'SSH access', + 'ssh_access_subtitle' => 'Stored login for live control', + 'cred_locked' => 'Locked', + 'cred_active' => 'Active', + 'cred_auth_key' => 'SSH key', + 'cred_auth_password' => 'Password', + 'cred_none_title' => 'No access stored', + 'cred_none_hint' => 'Without SSH access, live control is not possible.', + 'cred_deposit' => 'Store access', + + // ── Show: specifications ────────────────────────────────────────────── + 'specs_title' => 'Specifications', + 'specs_subtitle' => 'Hardware profile', + 'spec_arch' => 'Architecture', + 'spec_kernel' => 'Kernel', + 'spec_virt' => 'Virtualization', + 'spec_cores' => 'Cores', + 'spec_ram' => 'RAM', + 'spec_disk' => 'Disk', + 'spec_system' => 'System', + 'spec_package_manager' => 'Package manager', + 'spec_firewall' => 'Firewall', + 'gb' => ':value GB', + + // ── Show: vitals ────────────────────────────────────────────────────── + 'vital_cpu' => 'CPU', + 'vital_ram' => 'RAM', + 'vital_disk' => 'Disk', + 'load' => 'Load :value', + 'cores_count' => ':count cores', + 'ram_used_of_total' => ':used / :total GB', + 'disk_used_of_total' => ':used / :total GB', + + // ── Show: security / hardening ──────────────────────────────────────── + 'security_title' => 'Security', + 'security_subtitle' => 'Hardening checklist', + 'check_secure' => 'secure', + 'check_open' => 'open', + 'fail2ban_configure' => 'Configure fail2ban', + + // ── Show: firewall rules ────────────────────────────────────────────── + 'firewall_title' => 'Firewall rules', + 'firewall_unavailable' => 'Not available', + 'firewall_sub_active' => ' · active', + 'firewall_sub_inactive' => ' · inactive', + 'firewall_sub_not_installed' => ' · not installed', + 'firewall_add_rule' => 'Rule', + 'firewall_not_supported' => 'Firewall management is not available on this system.', + 'firewall_read_error' => 'Firewall status could not be read (connection/permissions). Please reload later.', + 'firewall_tool_not_installed' => ':tool is not installed.', + 'firewalld_inactive' => 'firewalld is installed but inactive.', + 'firewall_default' => 'Default', + 'firewall_incoming' => 'incoming: :value', + 'firewall_outgoing' => 'outgoing: :value', + 'firewall_zone' => 'Zone', + 'firewall_readonly' => 'firewalld: view only — please manage rules directly on the server.', + 'firewall_rule_from' => 'from :value', + 'firewall_remove_rule' => 'Remove rule', + 'firewall_no_rules' => 'No rules defined.', + + // ── Show: fail2ban status ───────────────────────────────────────────── + 'fail2ban_title' => 'fail2ban status', + 'fail2ban_unavailable' => 'Not available', + 'fail2ban_sub_active' => 'active · :count banned', + 'fail2ban_sub_installed_inactive' => 'installed · inactive', + 'fail2ban_sub_not_installed' => 'not installed', + 'fail2ban_ban_ip' => 'Ban IP', + 'fail2ban_not_supported' => 'fail2ban is not available on this system.', + 'fail2ban_read_error' => 'fail2ban status could not be read. Please reload later.', + 'fail2ban_state_installed_inactive' => 'fail2ban is installed but inactive.', + 'fail2ban_state_not_installed' => 'fail2ban is not installed.', + 'fail2ban_banned' => 'banned: :count', + 'fail2ban_failed' => 'failed attempts: :count', + 'fail2ban_no_banned' => 'No banned IPs.', + 'whitelist_label' => 'Whitelist (ignoreip)', + 'whitelist_placeholder' => 'IP or CIDR', + + // ── Show: volumes ───────────────────────────────────────────────────── + 'volumes_title' => 'Volumes', + 'mountpoints' => ':count mount points', + 'no_volumes' => 'No volumes read.', + + // ── Show: network interfaces ────────────────────────────────────────── + 'interfaces_title' => 'Network interfaces', + 'interfaces_count' => ':count interfaces', + 'col_name' => 'Name', + 'col_ip' => 'IP', + 'col_mac' => 'MAC', + 'col_rx' => 'RX', + 'col_tx' => 'TX', + + // ── Show: SSH keys ──────────────────────────────────────────────────── + 'ssh_keys_title' => 'SSH keys', + 'ssh_keys_authorized' => ':count authorized', + 'ssh_keys_add' => 'Add key', + 'ssh_keys_remove' => 'Remove key', + 'ssh_keys_none' => 'No authorized keys.', + + // ── Show: loading skeletons ─────────────────────────────────────────── + 'loading' => 'loading…', + + // ── Switcher ────────────────────────────────────────────────────────── + 'switcher_no_server' => 'No server', + + // ── Notifications (Show component) ──────────────────────────────────── + 'notify_access_locked' => 'Access locked.', + 'notify_access_unlocked' => 'Access unlocked.', + 'notify_access_deleted' => 'Access deleted.', + 'notify_remove_failed' => 'Removal failed: :error', + 'notify_key_removed' => 'Key “:name” removed.', + 'notify_rule_not_found' => 'Rule no longer exists.', + 'notify_firewall_rule_removed' => 'Firewall rule removed.', + 'notify_unban_failed' => 'Unban failed: :error', + 'notify_ip_unbanned' => 'IP :ip unbanned.', + 'notify_whitelist_error' => 'Whitelist: :error', + 'notify_ip_whitelisted' => ':ip added to the whitelist.', + 'notify_ip_unwhitelisted' => ':ip removed from the whitelist.', + 'error_unknown' => 'unknown', + 'error_failed' => 'failed', + + // ── Confirm modals (Show component) ─────────────────────────────────── + 'confirm_remove_key_heading' => 'Remove SSH key', + 'confirm_remove_key_body' => 'The key “:name” will lose access to :server.', + 'confirm_delete_credential_heading' => 'Delete access', + 'confirm_delete_credential_body' => 'The stored SSH access for :server will be removed permanently. Live control will no longer be possible afterwards.', + 'confirm_remove_rule_heading' => 'Remove firewall rule', + 'confirm_remove_rule_body' => 'The rule “:label” will be removed from the firewall of :server.', +]; diff --git a/lang/en/services.php b/lang/en/services.php new file mode 100644 index 0000000..6fc9353 --- /dev/null +++ b/lang/en/services.php @@ -0,0 +1,57 @@ +<?php + +// Services (systemd) page strings (R16). Shared buttons live in common.php. +return [ + // Header / overview + 'eyebrow' => 'Services', + 'heading' => 'Services', + 'fleet_active' => ':active / :total active', + + // systemd panel + 'panel_title' => 'systemd services', + 'panel_subtitle' => ':total units · :server', + 'search_label' => 'Search services', + 'search_placeholder' => 'Search service…', + + // Service status labels (status triad) + 'status_online' => 'active', + 'status_warning' => 'degraded', + 'status_offline' => 'stopped', + + // Service actions + 'start' => 'Start', + 'stop' => 'Stop', + 'restart' => 'Restart', + + // Empty state + 'empty_title' => 'No service found', + 'empty_hint' => 'Search “:search” returned no matches.', + + // Journal panel + 'journal_title' => 'Journal', + 'journal_subtitle' => 'journalctl -f · :server', + 'journal_live' => 'live', + 'journal_follow' => 'Following journal · :count lines', + + // Confirm modal — operations + 'confirm_start_heading' => 'Start service', + 'confirm_start_phrase' => 'will be started', + 'confirm_start_label' => 'Start', + 'confirm_stop_heading' => 'Stop service', + 'confirm_stop_phrase' => 'will be stopped', + 'confirm_stop_label' => 'Stop', + 'confirm_restart_heading' => 'Restart service', + 'confirm_restart_phrase' => 'will be restarted', + 'confirm_restart_label' => 'Restart', + 'confirm_body' => ':name :phrase. Executed via systemctl on :server.', + 'confirm_notify' => ':name: :label executed.', + + // Action results / notifications + 'action_error' => ':name: :error', + 'action_done' => ':name: :op executed.', + 'action_failed' => ':name: failed — :output', + 'no_permission' => 'no permission', + + // Page title + 'title' => 'Services — Clusev', +]; diff --git a/lang/en/settings.php b/lang/en/settings.php new file mode 100644 index 0000000..98e8e11 --- /dev/null +++ b/lang/en/settings.php @@ -0,0 +1,49 @@ +<?php + +// Settings (profile + security) page strings (R16). Shared buttons live in common.php. +return [ + // Identity header + 'account' => 'Account', + 'role_admin' => 'Administrator', + 'two_factor_on' => '2FA on', + 'two_factor_off' => '2FA off', + + // Section nav tabs + 'tab_profile' => 'Profile', + 'tab_security' => 'Security', + + // Profile panel + 'profile_title' => 'Profile', + 'profile_subtitle' => 'Name and email', + 'name' => 'Name', + 'email' => 'Email', + + // Password panel + 'password_title' => 'Password', + 'password_subtitle' => 'At least 10 characters', + 'current_password' => 'Current password', + 'new_password' => 'New password', + 'repeat_password' => 'Repeat', + 'change_password' => 'Change password', + + // Two-factor panel + 'twofa_title' => 'Two-factor authentication', + 'twofa_subtitle' => 'TOTP (authenticator app)', + 'twofa_status_on' => '2FA is active', + 'twofa_status_off' => '2FA is not set up', + 'twofa_hint_on' => 'Login requires a TOTP code.', + 'twofa_hint_off' => 'Recommended — protects login with a second factor.', + 'twofa_setup' => 'Set up', + + // Notifications + 'profile_saved' => 'Profile saved.', + 'password_changed' => 'Password changed.', + + // Disable-2FA confirmation modal + 'disable_2fa_heading' => 'Disable 2FA', + 'disable_2fa_body' => 'Two-factor authentication will be removed. Your account will then be protected by password only.', + 'disable_2fa_notify' => '2FA disabled.', + + // Page title + 'title' => 'Settings — Clusev', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php new file mode 100644 index 0000000..277c27e --- /dev/null +++ b/lang/en/shell.php @@ -0,0 +1,49 @@ +<?php + +// App shell: sidebar nav, topbar, command palette, layout chrome (R16). +return [ + // Sidebar — accessibility / brand + 'nav_aria' => 'Side navigation', + 'menu_close' => 'Close menu', + 'menu_open' => 'Open menu', + 'logout' => 'Sign out', + + // Sidebar — nav group labels + 'group_fleet' => 'Fleet', + 'group_account' => 'Account', + + // Sidebar — nav items + 'nav_dashboard' => 'Dashboard', + 'nav_servers' => 'Servers', + 'nav_services' => 'Services', + 'nav_files' => 'Files', + 'nav_audit' => 'Audit log', + 'nav_settings' => 'Settings', + 'nav_system' => 'System', + 'nav_versions' => 'Version', + + // Sidebar — user / 2FA badge + 'twofa_on' => '2FA on', + 'twofa_off' => '2FA off', + + // Topbar + 'title_default' => 'Overview', + 'commands_open_aria' => 'Open commands (Ctrl K)', + 'commands_title' => 'Commands (Ctrl K)', + 'shortcut_ctrl_k' => 'Ctrl K', + 'fleet_online' => ':online / :total online', + + // Command palette + 'cmdk_open' => 'Open commands', + 'cmdk_focus_search' => 'Focus search on page', + 'cmdk_show_help' => 'Show this help', + 'cmdk_placeholder' => 'Command or page…', + 'cmdk_no_match' => 'No match.', + 'cmdk_open_hint' => 'open', + 'cmdk_shortcuts_hint' => '? Shortcuts', + 'cmdk_esc' => 'Esc', + 'shortcuts_heading' => 'Keyboard shortcuts', + + // Toaster fallback + 'toast_done' => 'Done', +]; diff --git a/lang/en/system.php b/lang/en/system.php new file mode 100644 index 0000000..bd540c7 --- /dev/null +++ b/lang/en/system.php @@ -0,0 +1,47 @@ +<?php + +// System settings (Domain/TLS + release channel) page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'System', + 'heading' => 'Domain, TLS & release channel', + 'subheading' => 'Panel access address and update source', + 'https_active' => 'HTTPS active', + 'https_plain' => 'Plaintext HTTP', + + // Domain & TLS panel + 'tls_title' => 'Domain & TLS', + 'tls_subtitle' => 'Panel reachability and Let\'s Encrypt certificate', + 'tls_active_title' => 'TLS active — certificate issued automatically via Let\'s Encrypt', + 'tls_active_body_pre' => 'Reachable at', + 'tls_active_body_post' => 'issued and renewed automatically. Requirement: the domain points to this server via DNS.', + 'tls_bare_title' => 'Bare-IP mode: plaintext HTTP', + 'tls_bare_body' => 'Without a domain the panel — including 2FA and audit — runs over unencrypted HTTP. Enter a domain and TLS is then set up automatically, with no further steps.', + + // Read-only domain facts + 'domain' => 'Domain', + 'domain_via_ip' => '— (access via IP)', + 'access_address' => 'Access address', + 'access_bare_ip' => 'HTTP (bare IP)', + 'domain_note_pre' => 'The panel domain is set at installation and stays consistent with URL, WebSocket and cookie security. To change it, re-run', + 'domain_note_post' => 'with the new domain — TLS then sets itself up automatically.', + + // Release channel panel + 'channel_title' => 'Release channel', + 'channel_subtitle' => 'Source for updates', + 'channel_radiogroup_label' => 'Release channel', + 'channel_current' => 'Channel:', + + // Channel descriptions (CHANNELS array) + 'channel_stable' => 'Only tested releases approved by the maintainer (tagged versions). Recommended for production use.', + 'channel_beta' => 'Pre-releases of upcoming versions for testing new features. May be unstable. Development builds are never offered to users.', + + // Change-channel confirmation modal + 'change_channel_heading' => 'Switch release channel', + 'change_channel_body' => 'The release channel will be set to “:channel”. Updates will then be pulled from this source.', + 'change_channel_confirm' => 'Switch', + 'change_channel_notify' => 'Release channel set to “:channel”.', + + // Page title + 'title' => 'System — Clusev', +]; diff --git a/lang/en/versions.php b/lang/en/versions.php new file mode 100644 index 0000000..cf5aec8 --- /dev/null +++ b/lang/en/versions.php @@ -0,0 +1,47 @@ +<?php + +// Version & releases page strings (R16). Shared buttons live in common.php. +return [ + // Header + 'eyebrow' => 'Version', + 'heading' => 'Version & releases', + 'check_updates' => 'Check for updates', + + // Status banner + 'channel_badge' => 'Channel: :channel', + 'latest_release_badge' => 'latest release v:tag', + 'banner_default' => 'Installed version. “Check for updates” honestly compares it against the newest tagged release in the channel. Updating runs through the deploy (pull image + migrate).', + 'checked_at' => 'checked :time', + + // Update-check results (notifications / status line) + 'status_none_tagged' => 'No release tagged yet — installed version v:installed (channel: :channel).', + 'status_update_available' => 'Update available: v:latest (channel: :channel).', + 'status_current' => 'Up to date — v:installed (channel: :channel).', + + // Changelog panel + 'changelog_title' => 'Changelog', + 'changelog_subtitle' => ':count versions', + 'unreleased' => 'Unreleased', + 'in_progress' => 'in progress', + 'changelog_empty' => 'No changelog found.', + + // Installed panel + 'installed_title' => 'Installed', + 'running' => 'running', + 'build' => 'Build', + 'branch' => 'Branch', + 'channel' => 'Channel', + 'latest_release' => 'Latest release', + 'license' => 'License', + + // Update panel + 'update_title' => 'Update', + 'update_hint' => 'Updates arrive via tagged releases in the deploy — pull the new image and apply migrations:', + + // Project panel + 'project_title' => 'Project', + 'open_core' => 'Open Core · :license', + + // Page title + 'title' => 'Version — Clusev', +]; diff --git a/resources/views/components/lang-switch.blade.php b/resources/views/components/lang-switch.blade.php new file mode 100644 index 0000000..6cfc39f --- /dev/null +++ b/resources/views/components/lang-switch.blade.php @@ -0,0 +1,16 @@ +{{-- DE/EN language switch (R16). Plain links → full reload so the whole UI re-renders + in the chosen language; persists per-user + session via the locale route. --}} +@php $current = app()->getLocale(); @endphp +<div class="inline-flex shrink-0 items-center rounded-md border border-line bg-inset p-0.5 font-mono text-[10px] uppercase tracking-wider" + role="group" aria-label="Sprache / Language"> + @foreach (['de' => 'DE', 'en' => 'EN'] as $code => $abbr) + <a href="{{ route('locale.set', $code) }}" + @class([ + 'rounded px-2 py-1 transition-colors', + 'bg-accent/15 text-accent-text' => $current === $code, + 'text-ink-4 hover:text-ink-2' => $current !== $code, + ]) + aria-current="{{ $current === $code ? 'true' : 'false' }}" + title="{{ $code === 'de' ? 'Deutsch' : 'English' }}">{{ $abbr }}</a> + @endforeach +</div> diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 6f97f74..fab3209 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -2,7 +2,7 @@ <aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0" :class="{ '!translate-x-0': nav }" x-bind:inert="! nav && ! desktop" - aria-label="Seitennavigation"> + aria-label="{{ __('shell.nav_aria') }}"> {{-- Brand --}} <div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-line px-4"> <span class="grid h-8 w-8 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]"> @@ -11,7 +11,7 @@ <span class="font-display text-lg font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span> <button type="button" @click="nav = false" class="ml-auto grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink lg:hidden" - aria-label="Menü schließen"> + aria-label="{{ __('shell.menu_close') }}"> <x-icon name="x" class="h-5 w-5" /> </button> </div> @@ -23,17 +23,17 @@ {{-- Nav --}} <nav class="flex-1 space-y-1 overflow-y-auto p-3"> - <p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">Flotte</p> - <x-nav-item icon="dashboard" href="/" :active="request()->is('/')">Dashboard</x-nav-item> - <x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">Server</x-nav-item> - <x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">Dienste</x-nav-item> - <x-nav-item icon="folder" href="/files" :active="request()->is('files*')">Dateien</x-nav-item> - <x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">Audit-Log</x-nav-item> + <p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p> + <x-nav-item icon="dashboard" href="/" :active="request()->is('/')">{{ __('shell.nav_dashboard') }}</x-nav-item> + <x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">{{ __('shell.nav_servers') }}</x-nav-item> + <x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item> + <x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item> + <x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item> - <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">Konto</p> - <x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">Einstellungen</x-nav-item> - <x-nav-item icon="shield" href="/system" :active="request()->is('system*')">System</x-nav-item> - <x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">Version</x-nav-item> + <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p> + <x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item> + <x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item> + <x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">{{ __('shell.nav_versions') }}</x-nav-item> </nav> {{-- User --}} @@ -44,12 +44,12 @@ <span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">{{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }}</span> <span class="min-w-0 flex-1"> <span class="block truncate text-sm text-ink">{{ $u?->name ?? '—' }}</span> - <span class="block truncate font-mono text-[11px] {{ $u?->hasTwoFactorEnabled() ? 'text-online' : 'text-ink-3' }}">{{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }}</span> + <span class="block truncate font-mono text-[11px] {{ $u?->hasTwoFactorEnabled() ? 'text-online' : 'text-ink-3' }}">{{ $u?->hasTwoFactorEnabled() ? __('shell.twofa_on') : __('shell.twofa_off') }}</span> </span> </a> <form method="POST" action="{{ route('logout') }}" class="shrink-0"> @csrf - <button type="submit" class="grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="Abmelden"> + <button type="submit" class="grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="{{ __('shell.logout') }}"> <x-icon name="logout" class="h-[18px] w-[18px]" /> </button> </form> diff --git a/resources/views/components/topbar.blade.php b/resources/views/components/topbar.blade.php index dccc356..0bbc29c 100644 --- a/resources/views/components/topbar.blade.php +++ b/resources/views/components/topbar.blade.php @@ -1,24 +1,26 @@ -@props(['title' => 'Übersicht']) +@props(['title' => null]) +@php($title ??= __('shell.title_default')) <header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-base/90 px-4 backdrop-blur sm:px-6 lg:px-8"> <button type="button" @click="nav = true" class="grid min-h-11 min-w-11 shrink-0 place-items-center rounded-md text-ink-2 hover:bg-raised hover:text-ink lg:hidden" - aria-label="Menü öffnen"> + aria-label="{{ __('shell.menu_open') }}"> <x-icon name="menu" class="h-5 w-5" /> </button> <h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1> <div class="ml-auto flex min-w-0 items-center gap-2"> + <x-lang-switch /> <button type="button" @click="$dispatch('cmdk-open')" class="hidden items-center gap-2 rounded-md border border-line bg-inset px-2.5 py-1.5 text-ink-3 transition-colors hover:border-line-strong hover:text-ink sm:flex" - aria-label="Befehle öffnen (Strg K)" title="Befehle (Strg K)"> + aria-label="{{ __('shell.commands_open_aria') }}" title="{{ __('shell.commands_title') }}"> <x-icon name="command" class="h-3.5 w-3.5" /> - <span class="font-mono text-[10px] uppercase tracking-wider">Strg K</span> + <span class="font-mono text-[10px] uppercase tracking-wider">{{ __('shell.shortcut_ctrl_k') }}</span> </button> @php $fleetTotal = \App\Models\Server::count(); $fleetOnline = \App\Models\Server::where('status', 'online')->count(); @endphp - <x-status-pill :status="$fleetOnline > 0 ? 'online' : 'offline'">{{ $fleetOnline }} / {{ $fleetTotal }} online</x-status-pill> + <x-status-pill :status="$fleetOnline > 0 ? 'online' : 'offline'">{{ __('shell.fleet_online', ['online' => $fleetOnline, 'total' => $fleetTotal]) }}</x-status-pill> </div> </header> diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 552d236..4ed7b1c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="de"> +<html lang="{{ app()->getLocale() }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index 03b29f8..578524b 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="de"> +<html lang="{{ app()->getLocale() }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> @@ -28,10 +28,10 @@ <div class="relative z-10 flex max-w-md flex-col gap-7"> <div class="space-y-3"> <h1 class="text-balance font-display text-4xl font-bold leading-[1.12] tracking-tight text-ink"> - Eine Konsole für deine <span class="text-accent">gesamte Flotte</span>. + {{ __('auth.brand_headline_lead') }} <span class="text-accent">{{ __('auth.brand_headline_accent') }}</span>. </h1> <p class="max-w-sm text-sm leading-relaxed text-ink-2"> - Agentenlos über SSH. Metriken, Dienste, Dateien und ein lückenloses Audit-Log — sicher aus einem Panel gesteuert. + {{ __('auth.brand_tagline') }} </p> </div> @@ -42,27 +42,28 @@ <span class="h-2 w-2 rounded-full bg-warning/50"></span> <span class="h-2 w-2 rounded-full bg-online/50"></span> </span> - <span class="font-mono text-[10px] text-ink-4">clusev · sicherheitsmodell</span> + <span class="font-mono text-[10px] text-ink-4">{{ __('auth.terminal_title') }}</span> </div> <div class="space-y-2.5 p-4 font-mono text-xs leading-relaxed"> - <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">ssh</span> agentenlos · exec + SFTP über phpseclib</p> - <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">key</span> Host-Keys via TOFU gepinnt</p> - <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">2fa</span> erzwungen für jeden Login</p> - <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">log</span> jede Aktion im Audit-Log</p> - <p class="flex items-center gap-2.5 text-ink-3"><span class="w-8 shrink-0 text-cyan">core</span> Open Core · AGPL-lizenziert</p> + <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">ssh</span> {{ __('auth.terminal_ssh') }}</p> + <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">key</span> {{ __('auth.terminal_key') }}</p> + <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">2fa</span> {{ __('auth.terminal_2fa') }}</p> + <p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">log</span> {{ __('auth.terminal_log') }}</p> + <p class="flex items-center gap-2.5 text-ink-3"><span class="w-8 shrink-0 text-cyan">core</span> {{ __('auth.terminal_core') }}</p> </div> </div> </div> {{-- bottom: copyright + status --}} <div class="relative z-10 flex items-center justify-between gap-4 font-mono text-[11px] text-ink-4"> - <span>© {{ date('Y') }} Clusev · Fleet Control</span> - <span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-online"></span> betriebsbereit</span> + <span>{{ __('auth.footer_copyright', ['year' => date('Y')]) }}</span> + <span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-online"></span> {{ __('auth.footer_operational') }}</span> </div> </aside> {{-- Form panel --}} <main class="relative flex items-center justify-center px-5 py-10 sm:px-8"> + <div class="absolute right-5 top-5 sm:right-8"><x-lang-switch /></div> <div class="w-full max-w-sm"> {{-- wordmark for small screens (brand panel hidden) --}} <div class="mb-8 flex items-center gap-2.5 lg:hidden"> diff --git a/resources/views/livewire/audit/index.blade.php b/resources/views/livewire/audit/index.blade.php index 4f05ca1..a14e93b 100644 --- a/resources/views/livewire/audit/index.blade.php +++ b/resources/views/livewire/audit/index.blade.php @@ -2,17 +2,17 @@ {{-- Header --}} <div class="flex flex-wrap items-end justify-between gap-3"> <div> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Sicherheit</p> - <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Audit-Log</h2> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('audit.eyebrow') }}</p> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('audit.heading') }}</h2> </div> - <x-status-pill status="online">{{ $events->count() }} Ereignisse</x-status-pill> + <x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->count()]) }}</x-status-pill> </div> {{-- Ereignisliste --}} - <x-panel title="Ereignisse" subtitle="letzte 50 · neueste zuerst" :padded="false"> + <x-panel :title="__('audit.panel_title')" :subtitle="__('audit.panel_subtitle')" :padded="false"> <x-slot:actions> <label class="relative block"> - <span class="sr-only">Audit-Log durchsuchen</span> + <span class="sr-only">{{ __('audit.search_label') }}</span> <span class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-ink-4"> <x-icon name="search" class="h-3.5 w-3.5" /> </span> @@ -20,7 +20,7 @@ type="search" data-page-search wire:model.live.debounce.300ms="q" - placeholder="Akteur, Aktion, Ziel …" + placeholder="{{ __('audit.search_placeholder') }}" class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64" /> </label> @@ -59,12 +59,12 @@ <span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4"> <x-icon name="audit" class="h-5 w-5" /> </span> - <p class="font-display text-sm font-semibold text-ink-2">Keine Ereignisse</p> + <p class="font-display text-sm font-semibold text-ink-2">{{ __('audit.empty_title') }}</p> <p class="max-w-xs text-[11px] text-ink-4"> @if (trim($q) !== '') - Für „{{ $q }}“ wurden keine Audit-Einträge gefunden. + {{ __('audit.empty_filtered', ['query' => $q]) }} @else - Es liegen noch keine Audit-Einträge vor. + {{ __('audit.empty_none') }} @endif </p> </div> diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 6c37f64..ac8a847 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -6,21 +6,21 @@ <div class="space-y-7"> <div class="space-y-1.5"> - <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Control Plane</p> - <h1 class="font-display text-2xl font-bold tracking-tight text-ink">Anmelden</h1> - <p class="font-mono text-xs text-ink-3">Zugang zum Clusev-Panel.</p> + <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.control_plane') }}</p> + <h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.login_heading') }}</h1> + <p class="font-mono text-xs text-ink-3">{{ __('auth.login_subtitle') }}</p> </div> <form wire:submit="authenticate" class="space-y-4"> <div> - <label for="email" class="{{ $label }}">E-Mail</label> + <label for="email" class="{{ $label }}">{{ __('auth.email') }}</label> <input wire:model="email" id="email" type="email" autocomplete="username" autofocus class="{{ $field }}" placeholder="admin@clusev.local" /> @error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div> - <label for="password" class="{{ $label }}">Passwort</label> + <label for="password" class="{{ $label }}">{{ __('auth.password') }}</label> <input wire:model="password" id="password" type="password" autocomplete="current-password" class="{{ $field }}" placeholder="••••••••" /> @error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror @@ -28,24 +28,24 @@ <label class="flex items-center gap-2 font-mono text-xs text-ink-2"> <input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" /> - Angemeldet bleiben + {{ __('auth.remember_me') }} </label> <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="authenticate"> <svg wire:loading wire:target="authenticate" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - <span wire:loading.remove wire:target="authenticate">Anmelden</span> - <span wire:loading wire:target="authenticate">Prüfe…</span> + <span wire:loading.remove wire:target="authenticate">{{ __('auth.login_submit') }}</span> + <span wire:loading wire:target="authenticate">{{ __('auth.checking') }}</span> </x-btn> </form> <div class="flex items-center gap-3"> <span class="h-px flex-1 bg-line"></span> - <span class="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-4">Gesichert</span> + <span class="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-4">{{ __('auth.secured') }}</span> <span class="h-px flex-1 bg-line"></span> </div> <p class="flex items-start gap-2 font-mono text-[11px] leading-relaxed text-ink-4"> <x-icon name="shield" class="mt-px h-3.5 w-3.5 shrink-0 text-online" /> - Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log. + {{ __('auth.security_note') }} </p> </div> diff --git a/resources/views/livewire/auth/password-change.blade.php b/resources/views/livewire/auth/password-change.blade.php index 994fcb1..744db21 100644 --- a/resources/views/livewire/auth/password-change.blade.php +++ b/resources/views/livewire/auth/password-change.blade.php @@ -6,33 +6,33 @@ <div class="space-y-7"> <div class="space-y-1.5"> - <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Sicherheit</p> - <h1 class="font-display text-2xl font-bold tracking-tight text-ink">Passwort ändern</h1> - <p class="font-mono text-xs leading-relaxed text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p> + <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.security') }}</p> + <h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.password_change_heading') }}</h1> + <p class="font-mono text-xs leading-relaxed text-ink-3">{{ __('auth.password_change_subtitle') }}</p> </div> <form wire:submit="update" class="space-y-4"> <div> - <label for="current" class="{{ $label }}">Aktuelles Passwort</label> + <label for="current" class="{{ $label }}">{{ __('auth.current_password') }}</label> <input wire:model="current" id="current" type="password" autocomplete="current-password" class="{{ $field }}" /> @error('current') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div> - <label for="password" class="{{ $label }}">Neues Passwort</label> + <label for="password" class="{{ $label }}">{{ __('auth.new_password') }}</label> <input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" /> @error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div> - <label for="password_confirmation" class="{{ $label }}">Neues Passwort bestätigen</label> + <label for="password_confirmation" class="{{ $label }}">{{ __('auth.confirm_new_password') }}</label> <input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" /> </div> <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="update"> <svg wire:loading wire:target="update" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - <span wire:loading.remove wire:target="update">Speichern & weiter</span> - <span wire:loading wire:target="update">Speichere…</span> + <span wire:loading.remove wire:target="update">{{ __('auth.save_and_continue') }}</span> + <span wire:loading wire:target="update">{{ __('auth.saving') }}</span> </x-btn> </form> </div> diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index c007d9d..deeb336 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -6,14 +6,14 @@ <div class="space-y-7"> <div class="space-y-1.5"> - <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p> - <h1 class="font-display text-2xl font-bold tracking-tight text-ink">Bestätigung</h1> - <p class="font-mono text-xs text-ink-3">6-stelliger Code aus deiner Authenticator-App.</p> + <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p> + <h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.challenge_heading') }}</h1> + <p class="font-mono text-xs text-ink-3">{{ __('auth.challenge_subtitle') }}</p> </div> <form wire:submit="verify" class="space-y-4"> <div> - <label for="code" class="{{ $label }}">Code</label> + <label for="code" class="{{ $label }}">{{ __('auth.code') }}</label> <input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus class="{{ $field }}" placeholder="000000" /> @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror @@ -21,14 +21,14 @@ <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify"> <svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - <span wire:loading.remove wire:target="verify">Bestätigen</span> - <span wire:loading wire:target="verify">Prüfe…</span> + <span wire:loading.remove wire:target="verify">{{ __('common.confirm') }}</span> + <span wire:loading wire:target="verify">{{ __('auth.checking') }}</span> </x-btn> </form> <div class="flex justify-center"> <a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2"> - <x-icon name="chevron-left" class="h-3.5 w-3.5" /> Zurück zur Anmeldung + <x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }} </a> </div> </div> diff --git a/resources/views/livewire/auth/two-factor-setup.blade.php b/resources/views/livewire/auth/two-factor-setup.blade.php index f1c9064..068a2b0 100644 --- a/resources/views/livewire/auth/two-factor-setup.blade.php +++ b/resources/views/livewire/auth/two-factor-setup.blade.php @@ -6,24 +6,24 @@ <div class="space-y-6"> <div class="space-y-1.5"> - <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p> - <h1 class="font-display text-2xl font-bold tracking-tight text-ink">Einrichten</h1> - <p class="font-mono text-xs leading-relaxed text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, …) und bestätige mit einem Code.</p> + <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p> + <h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.setup_heading') }}</h1> + <p class="font-mono text-xs leading-relaxed text-ink-3">{{ __('auth.setup_subtitle') }}</p> </div> <div class="flex flex-col items-center gap-3"> <div class="rounded-lg border border-line bg-ink p-3"> - <img src="{{ $this->qrCode() }}" alt="2FA-QR-Code" class="h-44 w-44" /> + <img src="{{ $this->qrCode() }}" alt="{{ __('auth.qr_alt') }}" class="h-44 w-44" /> </div> <div class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center"> - <p class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p> + <p class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('auth.secret_manual') }}</p> <p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p> </div> </div> <form wire:submit="confirm" class="space-y-4"> <div> - <label for="code" class="{{ $label }}">Bestätigungs-Code</label> + <label for="code" class="{{ $label }}">{{ __('auth.confirmation_code') }}</label> <input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus class="{{ $field }}" placeholder="000000" /> @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror @@ -31,8 +31,8 @@ <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="confirm"> <svg wire:loading wire:target="confirm" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - <span wire:loading.remove wire:target="confirm">Aktivieren</span> - <span wire:loading wire:target="confirm">Prüfe…</span> + <span wire:loading.remove wire:target="confirm">{{ __('auth.activate') }}</span> + <span wire:loading wire:target="confirm">{{ __('auth.checking') }}</span> </x-btn> </form> </div> diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index c17135f..e4df14e 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,5 +1,5 @@ @php - $svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt']; + $svcLabel = ['online' => __('dashboard.svc_online'), 'warning' => __('dashboard.svc_warning'), 'offline' => __('dashboard.svc_offline')]; // Build SVG polyline paths from the real CPU/MEM history series (cache-backed, polled). $chart = function (array $s) { @@ -21,7 +21,7 @@ <div class="flex flex-wrap items-end justify-between gap-3"> <div> <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ $active?->name ?? '—' }}</p> - <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Übersicht</h2> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('dashboard.heading') }}</h2> </div> <x-status-pill :status="$active?->status ?? 'offline'">{{ $active?->ip ?? '—' }}</x-status-pill> </div> @@ -35,7 +35,7 @@ </div> {{-- Big dual-series chart --}} - <x-panel title="Auslastung" subtitle="CPU & Memory · 15 Min"> + <x-panel :title="__('dashboard.utilization')" :subtitle="__('dashboard.utilization_subtitle')"> <x-slot:actions> <span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-accent"></span><span class="text-ink-2">CPU</span></span> <span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-cyan"></span><span class="text-ink-2">MEM</span></span> @@ -74,21 +74,21 @@ </div> <div class="mt-1.5 flex justify-between font-mono text-[9px] text-ink-4"> - <span>-15 Min</span><span>-10</span><span>-5</span><span>jetzt</span> + <span>{{ __('dashboard.axis_minus_15min') }}</span><span>-10</span><span>-5</span><span>{{ __('dashboard.axis_now') }}</span> </div> </div> </x-panel> {{-- systemd table + audit --}} <div class="grid grid-cols-1 gap-3 lg:grid-cols-3"> - <x-panel title="systemd-Dienste" :subtitle="$active?->name" class="lg:col-span-2" :padded="false"> + <x-panel :title="__('dashboard.services_title')" :subtitle="$active?->name" class="lg:col-span-2" :padded="false"> <div class="overflow-x-auto"> <table class="w-full border-collapse"> <thead> <tr class="border-b border-line"> <th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">Unit</th> <th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">Status</th> - <th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">Zustand</th> + <th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ __('dashboard.col_state') }}</th> <th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">Boot</th> </tr> </thead> @@ -114,7 +114,7 @@ <td class="hidden px-4 py-2.5 text-right font-mono text-xs sm:table-cell {{ $svc['enabled'] ? 'text-ink-2' : 'text-ink-4' }}">{{ $svc['enabled'] ? 'enabled' : 'disabled' }}</td> </tr> @empty - <tr><td colspan="4" class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Dienstdaten — Server nicht verbunden.</td></tr> + <tr><td colspan="4" class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('dashboard.no_service_data') }}</td></tr> @endforelse @endif </tbody> @@ -122,7 +122,7 @@ </div> </x-panel> - <x-panel title="Audit-Log" subtitle="letzte Ereignisse" :padded="false"> + <x-panel :title="__('dashboard.audit_title')" :subtitle="__('dashboard.audit_subtitle')" :padded="false"> <div class="divide-y divide-line"> @forelse ($events as $e) <div class="flex items-start gap-3 px-4 py-2.5 sm:px-5"> @@ -134,7 +134,7 @@ <span class="shrink-0 font-mono text-[11px] text-ink-4">{{ $e->created_at->diffForHumans(null, true) }}</span> </div> @empty - <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Ereignisse.</p> + <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('dashboard.no_events') }}</p> @endforelse </div> </x-panel> diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 991dc9e..f22c99b 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -23,10 +23,10 @@ {{-- Header --}} <div class="flex flex-wrap items-end justify-between gap-3"> <div> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dateien</p> - <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dateien</h2> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('files.eyebrow') }}</p> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('files.heading') }}</h2> </div> - <x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? 'SFTP verbunden' : 'nicht verbunden' }}</x-status-pill> + <x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('files.sftp_connected') : __('files.sftp_disconnected') }}</x-status-pill> </div> {{-- Breadcrumb / path --}} @@ -47,23 +47,23 @@ </x-panel> {{-- Listing --}} - <x-panel :title="$path" :subtitle="$dirs . ' Ordner · ' . $files . ' Dateien'" :padded="false"> + <x-panel :title="$path" :subtitle="__('files.panel_subtitle', ['dirs' => $dirs, 'files' => $files])" :padded="false"> <x-slot:actions> <label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15"> <x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" /> <svg wire:loading wire:target="upload" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - Hochladen + {{ __('files.upload') }} <input type="file" wire:model="upload" class="hidden" /> </label> </x-slot:actions> {{-- Column header (desktop only) --}} <div class="hidden border-b border-line px-4 py-2 sm:px-5 lg:grid lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center lg:gap-4"> - <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Name</span> - <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Rechte</span> - <span class="text-right font-mono text-[10px] uppercase tracking-wider text-ink-4">Größe</span> - <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geändert</span> - <span class="sr-only">Aktionen</span> + <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_name') }}</span> + <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_perms') }}</span> + <span class="text-right font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_size') }}</span> + <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_modified') }}</span> + <span class="sr-only">{{ __('common.actions') }}</span> </div> <div class="divide-y divide-line" wire:loading.class="opacity-40" wire:target="load,open,go,up"> @@ -119,10 +119,10 @@ {{-- Row actions --}} <div class="flex shrink-0 items-center gap-1 lg:justify-end"> @unless ($isDir) - <x-btn variant="ghost" wire:click="download({{ $loop->index }})">Download</x-btn> - <x-btn variant="ghost" wire:click="edit({{ $loop->index }})">Bearbeiten</x-btn> + <x-btn variant="ghost" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn> + <x-btn variant="ghost" wire:click="edit({{ $loop->index }})">{{ __('common.edit') }}</x-btn> @endunless - <x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">Löschen</x-btn> + <x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-btn> </div> </div> @endforeach diff --git a/resources/views/livewire/modals/add-ssh-key.blade.php b/resources/views/livewire/modals/add-ssh-key.blade.php index 0f95c84..b22e4d9 100644 --- a/resources/views/livewire/modals/add-ssh-key.blade.php +++ b/resources/views/livewire/modals/add-ssh-key.blade.php @@ -4,30 +4,30 @@ <x-icon name="shield" class="h-5 w-5" /> </span> <div class="min-w-0 flex-1"> - <h2 class="font-display text-base font-semibold text-ink">SSH-Schlüssel hinzufügen</h2> - <p class="mt-1 text-sm leading-relaxed text-ink-2">Öffentlichen Schlüssel einfügen — er wird in <span class="font-mono text-ink-3">~/.ssh/authorized_keys</span> ergänzt.</p> + <h2 class="font-display text-base font-semibold text-ink">{{ __('modals.add_ssh_key.title') }}</h2> + <p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.add_ssh_key.subtitle_before') }} <span class="font-mono text-ink-3">~/.ssh/authorized_keys</span> {{ __('modals.add_ssh_key.subtitle_after') }}</p> </div> </div> <div class="mt-4"> <div class="mb-1.5 flex items-center justify-between"> - <label class="font-mono text-[11px] uppercase tracking-wider text-ink-3">Öffentlicher Schlüssel</label> + <label class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.add_ssh_key.public_key_label') }}</label> <button type="button" wire:click="generate" wire:target="generate" wire:loading.attr="disabled" class="font-mono text-[11px] text-accent-text transition-colors hover:text-accent disabled:opacity-50"> - <span wire:loading.remove wire:target="generate">Neues Paar generieren</span> - <span wire:loading wire:target="generate">generiere…</span> + <span wire:loading.remove wire:target="generate">{{ __('modals.add_ssh_key.generate') }}</span> + <span wire:loading wire:target="generate">{{ __('modals.add_ssh_key.generating') }}</span> </button> </div> <textarea wire:model="publicKey" rows="3" - placeholder="ssh-ed25519 AAAA… kommentar — oder oben generieren" + placeholder="{{ __('modals.add_ssh_key.public_key_placeholder') }}" class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" ></textarea> @if ($generatedPrivate) <div class="mt-3 rounded-md border border-warning/30 bg-warning/10 p-3"> <p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"> - <x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />Privater Schlüssel — erscheint nur jetzt, sicher speichern. + <x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.add_ssh_key.private_key_warning') }} </p> <textarea readonly rows="4" x-on:click="$el.select()" class="mt-2 w-full rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $generatedPrivate }}</textarea> @@ -42,11 +42,11 @@ </div> <div class="mt-6 flex items-center justify-end gap-2"> - <x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn> + <x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn> <x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled"> <x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" /> <svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - Hinzufügen + {{ __('common.add') }} </x-btn> </div> </div> diff --git a/resources/views/livewire/modals/confirm-action.blade.php b/resources/views/livewire/modals/confirm-action.blade.php index 1b93702..d6e5d21 100644 --- a/resources/views/livewire/modals/confirm-action.blade.php +++ b/resources/views/livewire/modals/confirm-action.blade.php @@ -17,7 +17,7 @@ </div> <div class="mt-6 flex items-center justify-end gap-2"> - <x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn> + <x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn> <x-btn :variant="$danger ? 'danger' : 'primary'" wire:click="confirm" wire:target="confirm" wire:loading.attr="disabled"> <x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-3.5 w-3.5" wire:loading.remove wire:target="confirm" /> <svg wire:loading wire:target="confirm" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> diff --git a/resources/views/livewire/modals/create-server.blade.php b/resources/views/livewire/modals/create-server.blade.php index 6df48f3..37ae4fe 100644 --- a/resources/views/livewire/modals/create-server.blade.php +++ b/resources/views/livewire/modals/create-server.blade.php @@ -4,28 +4,28 @@ <x-icon name="server" class="h-5 w-5" /> </span> <div class="min-w-0 flex-1"> - <h2 class="font-display text-base font-semibold text-ink">Server hinzufügen</h2> - <p class="mt-1 text-sm leading-relaxed text-ink-2">Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.</p> + <h2 class="font-display text-base font-semibold text-ink">{{ __('modals.create_server.title') }}</h2> + <p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.create_server.subtitle') }}</p> </div> </div> <div class="mt-4 space-y-3"> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name</label> - <input wire:model="name" type="text" placeholder="z. B. Prod-Web-01" maxlength="120" + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.name_label') }}</label> + <input wire:model="name" type="text" placeholder="{{ __('modals.create_server.name_placeholder') }}" maxlength="120" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> @error('name')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror </div> <div class="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div class="sm:col-span-2"> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">IP/Host</label> - <input wire:model="ip" type="text" placeholder="10.10.90.10 oder host.example.com" + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.ip_host_label') }}</label> + <input wire:model="ip" type="text" placeholder="{{ __('modals.create_server.ip_host_placeholder') }}" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> @error('ip')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">SSH-Port</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.ssh_port_label') }}</label> <input wire:model="sshPort" type="number" min="1" max="65535" placeholder="22" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> @error('sshPort')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror @@ -33,23 +33,23 @@ </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.user_label') }}</label> <input wire:model="username" type="text" placeholder="root" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> @error('username')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.auth_label') }}</label> <select wire:model.live="authType" class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none"> - <option value="password">Passwort</option> - <option value="key">Privater Schlüssel</option> + <option value="password">{{ __('modals.create_server.auth_password') }}</option> + <option value="key">{{ __('modals.create_server.auth_key') }}</option> </select> </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('modals.create_server.secret_key_label') : __('modals.create_server.secret_password_label') }}</label> @if ($authType === 'key') <textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea> @@ -62,26 +62,26 @@ @if ($authType === 'key') <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label> - <input wire:model="passphrase" type="password" placeholder="leer = keine" + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.passphrase_label') }}</label> + <input wire:model="passphrase" type="password" placeholder="{{ __('modals.create_server.passphrase_placeholder') }}" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> </div> @endif <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zugangs-Name (optional)</label> - <input wire:model="credentialName" type="text" placeholder="z. B. Root-Login · Deploy-User" maxlength="120" + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.credential_name_label') }}</label> + <input wire:model="credentialName" type="text" placeholder="{{ __('modals.create_server.credential_name_placeholder') }}" maxlength="120" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> @error('credentialName')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror </div> </div> <div class="mt-6 flex items-center justify-end gap-2"> - <x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn> + <x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn> <x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled"> <x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" /> <svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - Hinzufügen + {{ __('common.add') }} </x-btn> </div> </div> diff --git a/resources/views/livewire/modals/edit-credential.blade.php b/resources/views/livewire/modals/edit-credential.blade.php index 0ba9d2b..2a17911 100644 --- a/resources/views/livewire/modals/edit-credential.blade.php +++ b/resources/views/livewire/modals/edit-credential.blade.php @@ -4,32 +4,32 @@ <x-icon name="shield" class="h-5 w-5" /> </span> <div class="min-w-0 flex-1"> - <h2 class="font-display text-base font-semibold text-ink">SSH-Zugang</h2> - <p class="mt-1 text-sm leading-relaxed text-ink-2">Hier den Zugang hinterlegen — z. B. <span class="font-mono text-ink-3">root</span> für volle Steuerung (systemctl, Journal).</p> + <h2 class="font-display text-base font-semibold text-ink">{{ __('modals.edit_credential.title') }}</h2> + <p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.edit_credential.subtitle_before') }} <span class="font-mono text-ink-3">root</span> {{ __('modals.edit_credential.subtitle_after') }}</p> </div> </div> <div class="mt-4 space-y-3"> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name (optional)</label> - <input wire:model="name" type="text" placeholder="z. B. Prod-Server X · Backup-User" maxlength="120" + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.name_label') }}</label> + <input wire:model="name" type="text" placeholder="{{ __('modals.edit_credential.name_placeholder') }}" maxlength="120" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.user_label') }}</label> <input wire:model="username" type="text" placeholder="root" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.auth_label') }}</label> <select wire:model.live="authType" class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none"> - <option value="password">Passwort</option> - <option value="key">Privater Schlüssel</option> + <option value="password">{{ __('modals.edit_credential.auth_password') }}</option> + <option value="key">{{ __('modals.edit_credential.auth_key') }}</option> </select> </div> <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('modals.edit_credential.secret_key_label') : __('modals.edit_credential.secret_password_label') }}</label> @if ($authType === 'key') <textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea> @@ -40,8 +40,8 @@ </div> @if ($authType === 'key') <div> - <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label> - <input wire:model="passphrase" type="password" placeholder="leer = keine" + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.passphrase_label') }}</label> + <input wire:model="passphrase" type="password" placeholder="{{ __('modals.edit_credential.passphrase_placeholder') }}" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> </div> @endif @@ -54,11 +54,11 @@ </div> <div class="mt-6 flex items-center justify-end gap-2"> - <x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn> + <x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn> <x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled"> <x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" /> <svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - Speichern + {{ __('common.save') }} </x-btn> </div> </div> diff --git a/resources/views/livewire/modals/fail2ban-ban.blade.php b/resources/views/livewire/modals/fail2ban-ban.blade.php index 9346c7e..1ffb2d5 100644 --- a/resources/views/livewire/modals/fail2ban-ban.blade.php +++ b/resources/views/livewire/modals/fail2ban-ban.blade.php @@ -9,10 +9,9 @@ <x-icon name="shield" class="h-5 w-5" /> </span> <div class="min-w-0 flex-1"> - <h2 class="font-display text-base font-semibold text-ink">IP manuell sperren</h2> + <h2 class="font-display text-base font-semibold text-ink">{{ __('modals.fail2ban_ban.title') }}</h2> <p class="mt-1 text-sm leading-relaxed text-ink-2"> - Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang - können nicht gesperrt werden. + {{ __('modals.fail2ban_ban.subtitle') }} </p> </div> </div> @@ -26,7 +25,7 @@ <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"> <div> - <label class="{{ $label }}">Jail</label> + <label class="{{ $label }}">{{ __('modals.fail2ban_ban.jail_label') }}</label> <select wire:model="jail" class="{{ $select }}"> @forelse ($jails as $j) <option value="{{ $j }}">{{ $j }}</option> @@ -36,16 +35,16 @@ </select> </div> <div> - <label class="{{ $label }}">IP-Adresse</label> - <input wire:model="ip" type="text" placeholder="z. B. 203.0.113.10" class="{{ $field }}" /> + <label class="{{ $label }}">{{ __('modals.fail2ban_ban.ip_label') }}</label> + <input wire:model="ip" type="text" placeholder="{{ __('modals.fail2ban_ban.ip_placeholder') }}" class="{{ $field }}" /> </div> </div> <div class="mt-6 flex items-center justify-end gap-2"> - <x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn> + <x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn> <x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled"> <svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - IP sperren + {{ __('modals.fail2ban_ban.submit') }} </x-btn> </div> </div> diff --git a/resources/views/livewire/server-switcher.blade.php b/resources/views/livewire/server-switcher.blade.php index 0b753d7..32820fd 100644 --- a/resources/views/livewire/server-switcher.blade.php +++ b/resources/views/livewire/server-switcher.blade.php @@ -9,7 +9,7 @@ <span class="block truncate font-mono text-[11px] text-ink-3">{{ $active->ip }}</span> </span> @else - <span class="min-w-0 flex-1 text-sm text-ink-3">Kein Server</span> + <span class="min-w-0 flex-1 text-sm text-ink-3">{{ __('servers.switcher_no_server') }}</span> @endif <x-icon name="switcher" class="h-4 w-4 shrink-0 text-ink-3" /> </button> diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php index a723932..132d8ab 100644 --- a/resources/views/livewire/servers/index.blade.php +++ b/resources/views/livewire/servers/index.blade.php @@ -1,42 +1,42 @@ @php - $label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline']; + $label = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline')]; @endphp <div class="space-y-4"> {{-- Header --}} <div class="flex flex-wrap items-end justify-between gap-3"> <div> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Flotte</p> - <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Server</h2> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('servers.fleet_eyebrow') }}</p> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('servers.heading') }}</h2> </div> <div class="flex items-center gap-2"> <x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.create-server' })"> <x-icon name="plus" class="h-3.5 w-3.5" /> - Server hinzufügen + {{ __('servers.add_server') }} </x-btn> - <x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill> + <x-status-pill status="online">{{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}</x-status-pill> </div> </div> {{-- KPI grid: 1 → 2 → 4 --}} <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> - <x-kpi label="Gesamt" :value="$total" icon="server" /> - <x-kpi label="Online" :value="$online" tone="online" icon="activity" /> - <x-kpi label="Warnung" :value="$warning" tone="warning" /> - <x-kpi label="Offline" :value="$offline" tone="offline" /> + <x-kpi :label="__('servers.kpi_total')" :value="$total" icon="server" /> + <x-kpi :label="__('servers.kpi_online')" :value="$online" tone="online" icon="activity" /> + <x-kpi :label="__('servers.kpi_warning')" :value="$warning" tone="warning" /> + <x-kpi :label="__('servers.kpi_offline')" :value="$offline" tone="offline" /> </div> {{-- Fleet --}} - <x-panel title="Flotte" :subtitle="$total . ' im Cluster'" :padded="false"> + <x-panel :title="__('servers.fleet_title')" :subtitle="__('servers.in_cluster', ['count' => $total])" :padded="false"> <x-slot:actions> <label class="relative block"> - <span class="sr-only">Server suchen</span> + <span class="sr-only">{{ __('servers.search_label') }}</span> <x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" /> <input type="search" data-page-search wire:model.live.debounce.300ms="search" - placeholder="Name oder IP…" + placeholder="{{ __('servers.search_placeholder') }}" class="min-h-11 w-full rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56" /> </label> @@ -44,8 +44,8 @@ @if ($servers->isEmpty()) <div class="px-4 py-10 text-center sm:px-5"> - <p class="text-sm text-ink-2">Keine Server gefunden.</p> - <p class="mt-1 font-mono text-[11px] text-ink-4">Suchbegriff anpassen oder Filter zurücksetzen.</p> + <p class="text-sm text-ink-2">{{ __('servers.empty_title') }}</p> + <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('servers.empty_hint') }}</p> </div> @else <div class="divide-y divide-line"> diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 4cd7980..e710e3f 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -2,7 +2,7 @@ // Threshold tone for gauges/bars: >=90 offline, >=75 warning, else online. $tone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online'); $barColor = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline']; - $statusLabel = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$server->status] ?? ucfirst($server->status); + $statusLabel = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline')][$server->status] ?? ucfirst($server->status); $specs = $server->specs ?? []; $cores = $specs['cores'] ?? null; @@ -12,27 +12,27 @@ $diskUsed = $diskGb !== null ? (int) round((float) $diskGb * $server->disk / 100) : null; $specRows = [ - ['Architektur', $specs['arch'] ?? '—'], - ['Kernel', $specs['kernel'] ?? '—'], - ['Virtualisierung', $specs['virt'] ?? '—'], - ['Kerne', $cores ?? '—'], - ['RAM', $ramGb !== null ? $ramGb.' GB' : '—'], - ['Disk', $diskGb !== null ? $diskGb.' GB' : '—'], + [__('servers.spec_arch'), $specs['arch'] ?? '—'], + [__('servers.spec_kernel'), $specs['kernel'] ?? '—'], + [__('servers.spec_virt'), $specs['virt'] ?? '—'], + [__('servers.spec_cores'), $cores ?? '—'], + [__('servers.spec_ram'), $ramGb !== null ? __('servers.gb', ['value' => $ramGb]) : '—'], + [__('servers.spec_disk'), $diskGb !== null ? __('servers.gb', ['value' => $diskGb]) : '—'], ]; // Detected OS tooling (populated once the live profile loads). if (! empty($os)) { $pm = ($os['packageManager'] ?? 'none') !== 'none' ? $os['packageManager'] : '—'; $fw = ($os['firewallTool'] ?? 'none') !== 'none' ? $os['firewallTool'] : '—'; - $specRows[] = ['System', $os['familyLabel'] ?? '—']; - $specRows[] = ['Paketverwaltung', $pm]; - $specRows[] = ['Firewall', $fw]; + $specRows[] = [__('servers.spec_system'), $os['familyLabel'] ?? '—']; + $specRows[] = [__('servers.spec_package_manager'), $pm]; + $specRows[] = [__('servers.spec_firewall'), $fw]; } $vitals = [ - ['label' => 'CPU', 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? 'Last '.number_format($loadAvg, 2).' · ' : '').($cores ? $cores.' Kerne' : '—')], - ['label' => 'RAM', 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? $ramUsed.' / '.$ramGb.' GB' : '—'], - ['label' => 'Disk', 'icon' => 'server', 'val' => (int) $server->disk, 'sub' => $diskUsed !== null ? $diskUsed.' / '.$diskGb.' GB' : '—'], + ['label' => __('servers.vital_cpu'), 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? __('servers.load', ['value' => number_format($loadAvg, 2)]).' · ' : '').($cores ? __('servers.cores_count', ['count' => $cores]) : '—')], + ['label' => __('servers.vital_ram'), 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? __('servers.ram_used_of_total', ['used' => $ramUsed, 'total' => $ramGb]) : '—'], + ['label' => __('servers.vital_disk'), 'icon' => 'server', 'val' => (int) $server->disk, 'sub' => $diskUsed !== null ? __('servers.disk_used_of_total', ['used' => $diskUsed, 'total' => $diskGb]) : '—'], ]; @endphp @@ -40,7 +40,7 @@ <a href="{{ route('servers.index') }}" class="inline-flex min-h-11 items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.2em] text-ink-3 hover:text-ink-2"> <x-icon name="switcher" class="h-3.5 w-3.5 rotate-90" /> - Zurück zur Flotte + {{ __('servers.back_to_fleet') }} </a> {{-- Hero header --}} @@ -66,18 +66,18 @@ <span class="text-ink-4">·</span> <span>{{ $server->os }}</span> <span class="text-ink-4">·</span> - <span>Uptime {{ $server->uptime ?: '—' }}</span> + <span>{{ __('servers.uptime', ['value' => $server->uptime ?: '—']) }}</span> <span class="text-ink-4">·</span> - <span>zuletzt {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }}</span> + <span>{{ __('servers.last_seen', ['value' => optional($server->last_seen_at)->diffForHumans() ?? '—']) }}</span> </p> </div> </div> <div class="flex shrink-0 items-center gap-2"> <x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.system-update', arguments: { serverId: {{ $server->id }} } })"> - <x-icon name="rotate" class="h-3.5 w-3.5" /> System-Updates + <x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('servers.system_updates') }} </x-btn> <x-btn variant="secondary" wire:click="openFiles"> - <x-icon name="folder" class="h-3.5 w-3.5" /> Dateien + <x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('servers.files') }} </x-btn> </div> </div> @@ -85,7 +85,7 @@ @if ($ready && ! $connected) <div class="flex items-center gap-2.5 rounded-lg border border-warning/25 bg-warning/10 px-4 py-3"> <x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" /> - <p class="text-sm text-ink-2">Keine Live-Verbindung — gezeigt werden die zuletzt gespeicherten Werte. Zugang/Erreichbarkeit prüfen.</p> + <p class="text-sm text-ink-2">{{ __('servers.offline_banner') }}</p> </div> @endif @@ -93,10 +93,10 @@ @php $cred = $server->credential; $credStatus = $cred ? ($cred->disabled ? 'offline' : 'online') : null; - $credStatusLabel = $cred ? ($cred->disabled ? 'Gesperrt' : 'Aktiv') : null; - $credAuthLabel = $cred ? ($cred->auth_type === 'key' ? 'SSH-Key' : 'Passwort') : null; + $credStatusLabel = $cred ? ($cred->disabled ? __('servers.cred_locked') : __('servers.cred_active')) : null; + $credAuthLabel = $cred ? ($cred->auth_type === 'key' ? __('servers.cred_auth_key') : __('servers.cred_auth_password')) : null; @endphp - <x-panel title="SSH-Zugang" subtitle="Hinterlegte Anmeldung für die Live-Steuerung" :padded="false"> + <x-panel :title="__('servers.ssh_access_title')" :subtitle="__('servers.ssh_access_subtitle')" :padded="false"> @if ($cred) <div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5"> <span @class([ @@ -118,15 +118,15 @@ </div> <div class="flex shrink-0 flex-wrap items-center gap-2"> <x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })"> - <x-icon name="settings" class="h-3.5 w-3.5" /> Bearbeiten + <x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }} </x-btn> <x-btn variant="secondary" wire:click="toggleCredential" wire:target="toggleCredential" wire:loading.attr="disabled"> <x-icon name="power" class="h-3.5 w-3.5" wire:loading.remove wire:target="toggleCredential" /> <svg wire:loading wire:target="toggleCredential" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - {{ $cred->disabled ? 'Entsperren' : 'Sperren' }} + {{ $cred->disabled ? __('common.unlock') : __('common.lock') }} </x-btn> <x-btn variant="ghost-danger" wire:click="confirmDeleteCredential"> - <x-icon name="trash" class="h-3.5 w-3.5" /> Löschen + <x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }} </x-btn> </div> </div> @@ -136,11 +136,11 @@ <x-icon name="shield" class="h-5 w-5" /> </span> <div class="min-w-0 flex-1"> - <p class="truncate font-display text-sm font-semibold text-ink">Kein Zugang hinterlegt</p> - <p class="mt-1.5 font-mono text-[11px] text-ink-3">Ohne SSH-Zugang ist keine Live-Steuerung möglich.</p> + <p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p> + <p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p> </div> <x-btn variant="accent" class="shrink-0" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })"> - <x-icon name="plus" class="h-3.5 w-3.5" /> Zugang hinterlegen + <x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }} </x-btn> </div> @endif @@ -164,7 +164,7 @@ @if ($ready) {{-- Spezifikationen + Sicherheit --}} <div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> - <x-panel title="Spezifikationen" subtitle="Hardware-Profil" :padded="false"> + <x-panel :title="__('servers.specs_title')" :subtitle="__('servers.specs_subtitle')" :padded="false"> <dl class="divide-y divide-line"> @foreach ($specRows as [$key, $val]) <div class="flex items-center justify-between gap-3 px-4 py-2.5 sm:px-5"> @@ -175,7 +175,7 @@ </dl> </x-panel> - <x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false"> + <x-panel :title="__('servers.security_title')" :subtitle="__('servers.security_subtitle')" :padded="false"> <div class="divide-y divide-line"> @foreach ($hardening as $check) @php $supported = $check['supported'] ?? true; @endphp @@ -198,16 +198,16 @@ 'shrink-0 font-mono text-[10px] uppercase tracking-wider', 'text-online' => $check['secure'], 'text-warning' => ! $check['secure'], - ])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span> + ])>{{ $check['secure'] ? __('servers.check_secure') : __('servers.check_open') }}</span> @else - <span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">n. v.</span> + <span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.unsupported') }}</span> @endif </p> <p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p> </div> @if ($supported) @if ($check['key'] === 'fail2ban') - <x-btn variant="ghost" size="sm" icon class="shrink-0" title="fail2ban konfigurieren" + <x-btn variant="ghost" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}" wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })"> <x-icon name="settings" class="h-3.5 w-3.5" /> </x-btn> @@ -215,7 +215,7 @@ {{-- Uniform style across all rows (R10): always the bordered secondary button. --}} <x-btn variant="secondary" size="sm" class="shrink-0" wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })"> - {{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }} + {{ $check['featureOn'] ? __('common.disable') : __('common.enable') }} </x-btn> @endif </div> @@ -239,51 +239,51 @@ $fwDefaults = $fw['defaults'] ?? []; $actTone = ['ALLOW' => 'online', 'LIMIT' => 'warning', 'DENY' => 'offline', 'REJECT' => 'offline']; @endphp - <x-panel title="Firewall-Regeln" - :subtitle="$fwSupported ? ($fwToolLabel . ($fwInstalled ? ($fwActive ? ' · aktiv' : ' · inaktiv') : ' · nicht installiert')) : 'Nicht verfügbar'" + <x-panel :title="__('servers.firewall_title')" + :subtitle="$fwSupported ? ($fwToolLabel . ($fwInstalled ? ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive')) : __('servers.firewall_sub_not_installed'))) : __('servers.firewall_unavailable')" :padded="false"> @if ($fwSupported && $fwManageable) <x-slot:actions> <x-btn variant="accent" size="sm" wire:click="$dispatch('openModal', { component: 'modals.firewall-rule', arguments: { serverId: {{ $server->id }}, tool: '{{ $fwTool }}' } })"> - <x-icon name="plus" class="h-3.5 w-3.5" /> Regel + <x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.firewall_add_rule') }} </x-btn> </x-slot:actions> @endif @if (! $fwSupported) <div class="px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $fw['reason'] ?? 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.' }}</p> + <p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $fw['reason'] ?? __('servers.firewall_not_supported') }}</p> </div> @elseif ($fwReadError) <div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5"> <x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" /> - <p class="font-mono text-[11px] text-ink-3">Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.</p> + <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_read_error') }}</p> </div> @elseif (! $fwInstalled) <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] text-ink-3">{{ $fwToolLabel }} ist nicht installiert.</p> + <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_tool_not_installed', ['tool' => $fwToolLabel]) }}</p> <x-btn variant="secondary" size="sm" wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })"> - Aktivieren + {{ __('common.enable') }} </x-btn> </div> @elseif ($fwTool === 'firewalld' && ! $fwActive) <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] text-ink-3">firewalld ist installiert, aber inaktiv.</p> + <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewalld_inactive') }}</p> <x-btn variant="secondary" size="sm" wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })"> - Aktivieren + {{ __('common.enable') }} </x-btn> </div> @else <div class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5 sm:px-5"> @if ($fwTool === 'ufw') - <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Standard</span> - <x-badge tone="neutral">eingehend: {{ $fwDefaults['incoming'] ?? '—' }}</x-badge> - <x-badge tone="neutral">ausgehend: {{ $fwDefaults['outgoing'] ?? '—' }}</x-badge> + <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.firewall_default') }}</span> + <x-badge tone="neutral">{{ __('servers.firewall_incoming', ['value' => $fwDefaults['incoming'] ?? '—']) }}</x-badge> + <x-badge tone="neutral">{{ __('servers.firewall_outgoing', ['value' => $fwDefaults['outgoing'] ?? '—']) }}</x-badge> @else - <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Zone</span> + <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.firewall_zone') }}</span> <x-badge tone="neutral">{{ $fwDefaults['zone'] ?? '—' }}</x-badge> @endif </div> @@ -291,7 +291,7 @@ @if ($fwReadOnly) <div class="flex items-start gap-2.5 border-b border-line bg-inset px-4 py-2.5 sm:px-5"> <x-icon name="lock" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" /> - <p class="font-mono text-[11px] leading-relaxed text-ink-3">firewalld: nur Anzeige — Regeln bitte direkt am Server verwalten.</p> + <p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ __('servers.firewall_readonly') }}</p> </div> @endif @@ -310,7 +310,7 @@ <div class="min-w-0 flex-1"> <p class="truncate font-mono text-sm text-ink">{{ $rule['label'] ?? ($rule['to'] ?? $rule['raw']) }}</p> @if ($fwTool === 'ufw' && ($rule['from'] ?? '') !== '' && ! \Illuminate\Support\Str::contains($rule['from'], 'Anywhere')) - <p class="truncate font-mono text-[11px] text-ink-3">von {{ $rule['from'] }}</p> + <p class="truncate font-mono text-[11px] text-ink-3">{{ __('servers.firewall_rule_from', ['value' => $rule['from']]) }}</p> @endif </div> <span @class([ @@ -320,7 +320,7 @@ 'text-offline' => $ruleTone === 'offline', ])>{{ strtolower($rule['action'] ?? 'allow') }}</span> @unless ($fwReadOnly) - <x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="Regel entfernen" + <x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}" wire:click="confirmDeleteRule({{ $loop->index }})"> <x-icon name="trash" class="h-3.5 w-3.5" /> </x-btn> @@ -328,7 +328,7 @@ </div> @empty <div class="px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] text-ink-3">Keine Regeln definiert.</p> + <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_no_rules') }}</p> </div> @endforelse </div> @@ -346,37 +346,37 @@ $f2bIgnore = $f2b['ignoreip'] ?? []; $f2bBanned = array_sum(array_map(fn ($j) => $j['currentlyBanned'] ?? 0, $f2bJails)); @endphp - <x-panel title="fail2ban-Status" - :subtitle="$f2bSupported ? ($f2bInstalled ? ($f2bActive ? ('aktiv · ' . $f2bBanned . ' gesperrt') : 'installiert · inaktiv') : 'nicht installiert') : 'Nicht verfügbar'" + <x-panel :title="__('servers.fail2ban_title')" + :subtitle="$f2bSupported ? ($f2bInstalled ? ($f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')) : __('servers.fail2ban_sub_not_installed')) : __('servers.fail2ban_unavailable')" :padded="false"> @if ($f2bSupported && $f2bInstalled && $f2bActive) <x-slot:actions> - <x-btn variant="ghost" size="sm" icon title="fail2ban konfigurieren" + <x-btn variant="ghost" size="sm" icon title="{{ __('servers.fail2ban_configure') }}" wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })"> <x-icon name="settings" class="h-3.5 w-3.5" /> </x-btn> <x-btn variant="accent" size="sm" wire:click="$dispatch('openModal', { component: 'modals.fail2ban-ban', arguments: { serverId: {{ $server->id }}, jails: {{ \Illuminate\Support\Js::from(array_map(fn ($j) => $j['name'], $f2bJails)) }} } })"> - <x-icon name="lock" class="h-3.5 w-3.5" /> IP sperren + <x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }} </x-btn> </x-slot:actions> @endif @if (! $f2bSupported) <div class="px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $f2b['reason'] ?? 'fail2ban ist auf diesem System nicht verfügbar.' }}</p> + <p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $f2b['reason'] ?? __('servers.fail2ban_not_supported') }}</p> </div> @elseif ($f2bReadError) <div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5"> <x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" /> - <p class="font-mono text-[11px] text-ink-3">fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.</p> + <p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_read_error') }}</p> </div> @elseif (! $f2bInstalled || ! $f2bActive) <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] text-ink-3">fail2ban ist {{ $f2bInstalled ? 'installiert, aber inaktiv' : 'nicht installiert' }}.</p> + <p class="font-mono text-[11px] text-ink-3">{{ $f2bInstalled ? __('servers.fail2ban_state_installed_inactive') : __('servers.fail2ban_state_not_installed') }}</p> <x-btn variant="secondary" size="sm" wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'fail2ban', enable: true } })"> - Aktivieren + {{ __('common.enable') }} </x-btn> </div> @else @@ -386,8 +386,8 @@ <div class="flex flex-wrap items-center justify-between gap-2"> <p class="font-mono text-sm text-ink">{{ $jail['name'] }}</p> <p class="font-mono text-[11px] text-ink-3"> - <span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>gebannt: {{ $jail['currentlyBanned'] ?? 0 }}</span> - · Fehlversuche: {{ $jail['currentlyFailed'] ?? 0 }} + <span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>{{ __('servers.fail2ban_banned', ['count' => $jail['currentlyBanned'] ?? 0]) }}</span> + · {{ __('servers.fail2ban_failed', ['count' => $jail['currentlyFailed'] ?? 0]) }} </p> </div> @if (count($jail['bannedIps'] ?? [])) @@ -395,12 +395,12 @@ @foreach ($jail['bannedIps'] as $ip) <div class="flex items-center justify-between gap-2"> <span class="font-mono text-[11px] text-ink-2">{{ $ip }}</span> - <x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">Entsperren</x-btn> + <x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">{{ __('common.unlock') }}</x-btn> </div> @endforeach </div> @else - <p class="mt-1 font-mono text-[11px] text-ink-4">Keine gebannten IPs.</p> + <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('servers.fail2ban_no_banned') }}</p> @endif </div> @endforeach @@ -408,13 +408,13 @@ {{-- Whitelist (ignoreip) --}} <div class="border-t border-line px-4 py-3 sm:px-5"> - <p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">Whitelist (ignoreip)</p> + <p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.whitelist_label') }}</p> <div class="flex flex-wrap gap-1.5"> @foreach ($f2bIgnore as $ip) <span class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2"> {{ $ip }} @unless (in_array($ip, ['127.0.0.1/8', '::1'], true)) - <button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="Entfernen"> + <button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}"> <x-icon name="x" class="h-3 w-3" /> </button> @endunless @@ -422,9 +422,9 @@ @endforeach </div> <div class="mt-2.5 flex items-center gap-2"> - <input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="IP oder CIDR" + <input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="{{ __('servers.whitelist_placeholder') }}" class="h-8 w-full max-w-xs rounded-md border border-line bg-inset px-2.5 font-mono text-[11px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> - <x-btn variant="secondary" size="sm" wire:click="addIgnore">Hinzufügen</x-btn> + <x-btn variant="secondary" size="sm" wire:click="addIgnore">{{ __('common.add') }}</x-btn> </div> </div> @endif @@ -432,7 +432,7 @@ {{-- Volumes + Netzwerk-Interfaces --}} <div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> - <x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false"> + <x-panel :title="__('servers.volumes_title')" :subtitle="__('servers.mountpoints', ['count' => count($volumes)])" :padded="false"> <div class="divide-y divide-line"> @forelse ($volumes as $vol) @php $vt = $tone((int) $vol['used']); @endphp @@ -451,21 +451,21 @@ </div> </div> @empty - <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Volumes gelesen.</p> + <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.no_volumes') }}</p> @endforelse </div> </x-panel> - <x-panel title="Netzwerk-Interfaces" :subtitle="count($interfaces) . ' Schnittstellen'" :padded="false"> + <x-panel :title="__('servers.interfaces_title')" :subtitle="__('servers.interfaces_count', ['count' => count($interfaces)])" :padded="false"> <div class="overflow-x-auto"> <table class="w-full min-w-[34rem] text-left"> <thead> <tr class="border-b border-line font-mono text-[10px] uppercase tracking-wider text-ink-4"> - <th class="px-4 py-2 font-medium sm:px-5">Name</th> - <th class="px-4 py-2 font-medium">IP</th> - <th class="px-4 py-2 font-medium">MAC</th> - <th class="px-4 py-2 text-right font-medium">RX</th> - <th class="px-4 py-2 pr-4 text-right font-medium sm:pr-5">TX</th> + <th class="px-4 py-2 font-medium sm:px-5">{{ __('servers.col_name') }}</th> + <th class="px-4 py-2 font-medium">{{ __('servers.col_ip') }}</th> + <th class="px-4 py-2 font-medium">{{ __('servers.col_mac') }}</th> + <th class="px-4 py-2 text-right font-medium">{{ __('servers.col_rx') }}</th> + <th class="px-4 py-2 pr-4 text-right font-medium sm:pr-5">{{ __('servers.col_tx') }}</th> </tr> </thead> <tbody class="divide-y divide-line"> @@ -485,10 +485,10 @@ </div> {{-- SSH-Schlüssel --}} - <x-panel title="SSH-Schlüssel" :subtitle="count($sshKeys) . ' autorisiert'" :padded="false"> + <x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false"> <x-slot:actions> <x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.add-ssh-key', arguments: { serverId: {{ $server->id }} } })"> - <x-icon name="plus" class="h-3.5 w-3.5" /> Schlüssel hinzufügen + <x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }} </x-btn> </x-slot:actions> <div class="divide-y divide-line"> @@ -504,20 +504,20 @@ </div> <p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p> </div> - <x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="Schlüssel entfernen"> + <x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}"> <x-icon name="trash" class="h-3.5 w-3.5" /> </x-btn> </div> @empty - <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine autorisierten Schlüssel.</p> + <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p> @endforelse </div> </x-panel> @else {{-- skeletons for the SSH-loaded sections --}} <div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> - @foreach (['Spezifikationen', 'Sicherheit'] as $t) - <x-panel :title="$t" subtitle="lädt…" :padded="false"> + @foreach ([__('servers.specs_title'), __('servers.security_title')] as $t) + <x-panel :title="$t" :subtitle="__('servers.loading')" :padded="false"> <div class="space-y-3 p-4 sm:p-5"> <x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-5/6" /><x-skeleton class="h-3 w-1/2" /> </div> @@ -525,15 +525,15 @@ @endforeach </div> <div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> - @foreach (['Volumes', 'Netzwerk-Interfaces'] as $t) - <x-panel :title="$t" subtitle="lädt…" :padded="false"> + @foreach ([__('servers.volumes_title'), __('servers.interfaces_title')] as $t) + <x-panel :title="$t" :subtitle="__('servers.loading')" :padded="false"> <div class="space-y-3 p-4 sm:p-5"> <x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-3/4" /><x-skeleton class="h-3 w-5/6" /> </div> </x-panel> @endforeach </div> - <x-panel title="SSH-Schlüssel" subtitle="lädt…" :padded="false"> + <x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.loading')" :padded="false"> <div class="space-y-3 p-4 sm:p-5"><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-1/2" /></div> </x-panel> @endif diff --git a/resources/views/livewire/services/index.blade.php b/resources/views/livewire/services/index.blade.php index 904710b..3c7ca2f 100644 --- a/resources/views/livewire/services/index.blade.php +++ b/resources/views/livewire/services/index.blade.php @@ -1,5 +1,5 @@ @php - $svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt']; + $svcLabel = ['online' => __('services.status_online'), 'warning' => __('services.status_warning'), 'offline' => __('services.status_offline')]; $list = $this->filteredServices; $total = count($services); $active = collect($services)->where('status', 'online')->count(); @@ -14,24 +14,24 @@ {{-- Header --}} <div class="flex flex-wrap items-end justify-between gap-3"> <div> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dienste</p> - <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dienste</h2> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('services.eyebrow') }}</p> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('services.heading') }}</h2> <p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $server }}</p> </div> - <x-status-pill :status="$fleetTone">{{ $active }} / {{ $total }} aktiv</x-status-pill> + <x-status-pill :status="$fleetTone">{{ __('services.fleet_active', ['active' => $active, 'total' => $total]) }}</x-status-pill> </div> {{-- systemd services --}} - <x-panel title="systemd-Dienste" :subtitle="$total . ' Units · ' . $server" :padded="false"> + <x-panel :title="__('services.panel_title')" :subtitle="__('services.panel_subtitle', ['total' => $total, 'server' => $server])" :padded="false"> <x-slot:actions> <label class="relative block"> - <span class="sr-only">Dienste durchsuchen</span> + <span class="sr-only">{{ __('services.search_label') }}</span> <x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" /> <input type="search" data-page-search wire:model.live.debounce.250ms="search" - placeholder="Dienst suchen…" + placeholder="{{ __('services.search_placeholder') }}" class="h-9 w-40 rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56" /> </label> @@ -71,16 +71,16 @@ {{-- R5: wire to wire-elements/modal --}} <div class="flex items-center gap-1.5"> - <x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn> - <x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn> - <x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn> + <x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-btn> + <x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-btn> + <x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-btn> </div> </div> </div> @empty <div class="px-4 py-10 text-center sm:px-5"> - <p class="font-mono text-sm text-ink-3">Kein Dienst gefunden</p> - <p class="mt-1 font-mono text-[11px] text-ink-4">Suche „{{ $search }}“ ergab keine Treffer.</p> + <p class="font-mono text-sm text-ink-3">{{ __('services.empty_title') }}</p> + <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('services.empty_hint', ['search' => $search]) }}</p> </div> @endforelse @endif @@ -88,9 +88,9 @@ </x-panel> {{-- Journal --}} - <x-panel title="Journal" :subtitle="'journalctl -f · ' . $server" :padded="false"> + <x-panel :title="__('services.journal_title')" :subtitle="__('services.journal_subtitle', ['server' => $server])" :padded="false"> <x-slot:actions> - <x-badge tone="cyan">live</x-badge> + <x-badge tone="cyan">{{ __('services.journal_live') }}</x-badge> </x-slot:actions> <div class="overflow-x-auto"> @@ -108,7 +108,7 @@ <div class="border-t border-line px-4 py-2 sm:px-5"> <p class="flex items-center gap-1.5 font-mono text-[11px] text-ink-4"> <span class="inline-flex h-1.5 w-1.5 rounded-full bg-online"></span> - Folge Journal · {{ count($journal) }} Zeilen + {{ __('services.journal_follow', ['count' => count($journal)]) }} </p> </div> </x-panel> diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index e65be38..8ddc5d8 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -5,8 +5,8 @@ $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; $tabs = [ - ['key' => 'profile', 'label' => 'Profil', 'icon' => 'settings'], - ['key' => 'security', 'label' => 'Sicherheit', 'icon' => 'shield'], + ['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'], + ['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'], ]; @endphp @@ -15,18 +15,18 @@ <div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel"> <span class="grid h-14 w-14 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 font-display text-xl font-semibold text-accent">{{ $initials }}</span> <div class="min-w-0 flex-1"> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Konto</p> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('settings.account') }}</p> <h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">{{ $u->name }}</h2> <p class="truncate font-mono text-[11px] text-ink-3">{{ $u->email }}</p> </div> <div class="flex items-center gap-2"> - <x-badge tone="neutral">Administrator</x-badge> + <x-badge tone="neutral">{{ __('settings.role_admin') }}</x-badge> <span @class([ 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]', 'border-online/30 text-online' => $twoFactorEnabled, 'border-line text-ink-3' => ! $twoFactorEnabled, ])> - <x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />{{ $twoFactorEnabled ? '2FA aktiv' : '2FA aus' }} + <x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />{{ $twoFactorEnabled ? __('settings.two_factor_on') : __('settings.two_factor_off') }} </span> </div> </div> @@ -48,68 +48,68 @@ {{-- Content --}} <div class="space-y-5"> @if ($tab === 'profile') - <x-panel title="Profil" subtitle="Name und E-Mail"> + <x-panel :title="__('settings.profile_title')" :subtitle="__('settings.profile_subtitle')"> <form wire:submit="updateProfile" class="space-y-4"> <div> - <label class="{{ $label }}">Name</label> + <label class="{{ $label }}">{{ __('settings.name') }}</label> <input wire:model="name" type="text" class="{{ $field }}" /> @error('name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div> - <label class="{{ $label }}">E-Mail</label> + <label class="{{ $label }}">{{ __('settings.email') }}</label> <input wire:model="email" type="email" class="{{ $field }} font-mono" /> @error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div class="flex justify-end"> <x-btn variant="primary" type="submit" wire:target="updateProfile" wire:loading.attr="disabled"> <svg wire:loading wire:target="updateProfile" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - Speichern + {{ __('common.save') }} </x-btn> </div> </form> </x-panel> @else - <x-panel title="Passwort" subtitle="Mindestens 10 Zeichen"> + <x-panel :title="__('settings.password_title')" :subtitle="__('settings.password_subtitle')"> <form wire:submit="updatePassword" class="space-y-4"> <div> - <label class="{{ $label }}">Aktuelles Passwort</label> + <label class="{{ $label }}">{{ __('settings.current_password') }}</label> <input wire:model="current_password" type="password" autocomplete="current-password" class="{{ $field }} font-mono" /> @error('current_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div class="grid gap-4 sm:grid-cols-2"> <div> - <label class="{{ $label }}">Neues Passwort</label> + <label class="{{ $label }}">{{ __('settings.new_password') }}</label> <input wire:model="password" type="password" autocomplete="new-password" class="{{ $field }} font-mono" /> @error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror </div> <div> - <label class="{{ $label }}">Wiederholen</label> + <label class="{{ $label }}">{{ __('settings.repeat_password') }}</label> <input wire:model="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }} font-mono" /> </div> </div> <div class="flex justify-end"> <x-btn variant="primary" type="submit" wire:target="updatePassword" wire:loading.attr="disabled"> <svg wire:loading wire:target="updatePassword" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> - Passwort ändern + {{ __('settings.change_password') }} </x-btn> </div> </form> </x-panel> - <x-panel title="Zwei-Faktor-Authentifizierung" subtitle="TOTP (Authenticator-App)"> + <x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')"> <div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex items-center gap-3"> <x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" /> <div> - <p class="text-sm text-ink">{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}</p> - <p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}</p> + <p class="text-sm text-ink">{{ $twoFactorEnabled ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p> + <p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p> </div> </div> @if ($twoFactorEnabled) - <x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">Deaktivieren</x-btn> + <x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn> @else <x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate> - <x-icon name="shield" class="h-3.5 w-3.5" /> Einrichten + <x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }} </x-btn> @endif </div> diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 311a7c9..ec27646 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -5,86 +5,70 @@ <x-icon name="settings" class="h-6 w-6" /> </span> <div class="min-w-0 flex-1"> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">System</p> - <h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">Domain, TLS & Release-Kanal</h2> - <p class="truncate font-mono text-[11px] text-ink-3">Zugriffsadresse des Panels und Update-Quelle</p> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('system.eyebrow') }}</p> + <h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">{{ __('system.heading') }}</h2> + <p class="truncate font-mono text-[11px] text-ink-3">{{ __('system.subheading') }}</p> </div> <span @class([ 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]', 'border-online/30 text-online' => $hasTls, 'border-warning/30 text-warning' => ! $hasTls, ])> - <x-status-dot :status="$hasTls ? 'online' : 'warning'" />{{ $hasTls ? 'HTTPS aktiv' : 'Klartext-HTTP' }} + <x-status-dot :status="$hasTls ? 'online' : 'warning'" />{{ $hasTls ? __('system.https_active') : __('system.https_plain') }} </span> </div> {{-- Domain & TLS --}} - <x-panel title="Domain & TLS" subtitle="Erreichbarkeit des Panels und Let's-Encrypt-Zertifikat"> + <x-panel :title="__('system.tls_title')" :subtitle="__('system.tls_subtitle')"> {{-- Current TLS state --}} @if ($hasTls) <div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3"> <x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" /> <div class="min-w-0"> - <p class="text-sm text-ink">TLS aktiv — Zertifikat automatisch über Let's Encrypt</p> - <p class="mt-0.5 font-mono text-[11px] text-ink-3">Erreichbar unter + <p class="text-sm text-ink">{{ __('system.tls_active_title') }}</p> + <p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ __('system.tls_active_body_pre') }} <a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a> · - wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.</p> + {{ __('system.tls_active_body_post') }}</p> </div> </div> @else <div class="mb-4 flex items-start gap-3 rounded-md border border-warning/25 bg-warning/5 p-3"> <x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" /> <div class="min-w-0"> - <p class="text-sm text-ink">Bare-IP-Modus: Klartext-HTTP</p> - <p class="mt-0.5 font-mono text-[11px] text-ink-3">Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen — TLS wird dann automatisch eingerichtet, ohne weitere Schritte.</p> + <p class="text-sm text-ink">{{ __('system.tls_bare_title') }}</p> + <p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ __('system.tls_bare_body') }}</p> </div> </div> @endif - {{-- Architecture rationale: why TLS is terminated by Caddy (collapsible) --}} - <div class="mb-4 rounded-md border border-line bg-inset p-3" x-data="{ open: false }"> - <button type="button" @click="open = ! open" class="flex w-full items-center gap-2 text-left"> - <x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-ink-3" /> - <span class="text-sm text-ink-2">Warum läuft TLS über Caddy?</span> - <span class="ml-auto shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4" x-text="open ? 'schließen' : 'mehr'"></span> - </button> - <div x-show="open" x-cloak x-transition class="mt-2.5 space-y-1.5 font-mono text-[11px] leading-relaxed text-ink-3"> - <p>Clusev nutzt <span class="text-ink-2">genau einen</span> nach außen offenen Dienst: Caddy. Es terminiert TLS, holt und erneuert das Let's-Encrypt-Zertifikat automatisch und leitet die Reverb-WebSockets (wss) über dieselbe Adresse — ohne Skript, Cronjob oder Reload-Hook.</p> - <p>App und Reverb bleiben intern erreichbar (kein offener Port nach außen). Caddy ist ein eigener Container mit eigener Version, daher kann ein App-Update die TLS-Terminierung nie unterbrechen.</p> - <p>Ein zusätzliches certbot + nginx wären <span class="text-ink-2">mehr</span> bewegliche Teile (eigene 443-Konfiguration, WebSocket-Upgrade, Renewal-Timer, Reload-Hook) — bei nur einer Domain kein Gewinn.</p> - </div> - </div> - {{-- Read-only: the panel domain is an install-time decision (kept consistent with URL/WebSocket/cookie security); TLS provisions itself automatically. --}} <dl class="divide-y divide-line overflow-hidden rounded-md border border-line"> <div class="flex items-center justify-between gap-3 px-4 py-2.5"> - <dt class="text-sm text-ink-2">Domain</dt> - <dd class="truncate font-mono text-sm text-ink">{{ $domain !== '' ? $domain : '— (Zugriff per IP)' }}</dd> + <dt class="text-sm text-ink-2">{{ __('system.domain') }}</dt> + <dd class="truncate font-mono text-sm text-ink">{{ $domain !== '' ? $domain : __('system.domain_via_ip') }}</dd> </div> <div class="flex items-center justify-between gap-3 px-4 py-2.5"> - <dt class="text-sm text-ink-2">Zugriffsadresse</dt> + <dt class="text-sm text-ink-2">{{ __('system.access_address') }}</dt> <dd class="truncate font-mono text-sm text-ink"> @if ($panelUrl) <a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a> @else - HTTP (Bare-IP) + {{ __('system.access_bare_ip') }} @endif </dd> </div> </dl> <p class="mt-2.5 font-mono text-[11px] leading-relaxed text-ink-4"> - Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und - Cookie-Sicherheit konsistent. Zum Ändern <span class="text-ink-3">install.sh</span> mit der - neuen Domain erneut ausführen — TLS richtet sich danach automatisch ein. + {{ __('system.domain_note_pre') }} <span class="text-ink-3">install.sh</span> {{ __('system.domain_note_post') }} </p> </x-panel> {{-- Release-Kanal --}} - <x-panel title="Release-Kanal" subtitle="Quelle für Updates"> + <x-panel :title="__('system.channel_title')" :subtitle="__('system.channel_subtitle')"> <div class="space-y-4"> {{-- Segmented control --}} - <div role="radiogroup" aria-label="Release-Kanal" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1"> + <div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1"> @foreach ($channels as $key => $desc) <button type="button" role="radio" @@ -104,7 +88,7 @@ <div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3"> <x-icon name="git-branch" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" /> <div class="min-w-0"> - <p class="text-sm text-ink">Kanal: <span class="font-mono text-accent-text">{{ $channel }}</span></p> + <p class="text-sm text-ink">{{ __('system.channel_current') }} <span class="font-mono text-accent-text">{{ $channel }}</span></p> <p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $channels[$channel] ?? '' }}</p> </div> </div> diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index 75ed950..dccc3be 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -19,13 +19,13 @@ {{-- Header --}} <div class="flex flex-wrap items-end justify-between gap-3"> <div> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Version</p> - <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Version & Releases</h2> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('versions.eyebrow') }}</p> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('versions.heading') }}</h2> </div> <x-btn variant="secondary" wire:click="checkUpdates" wire:target="checkUpdates" wire:loading.attr="disabled"> <svg wire:loading wire:target="checkUpdates" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> <x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="checkUpdates" /> - Nach Updates suchen + {{ __('versions.check_updates') }} </x-btn> </div> @@ -45,27 +45,27 @@ <div class="min-w-0 flex-1"> <p class="flex flex-wrap items-center gap-2 font-display text-base font-semibold text-ink"> Clusev <span class="font-mono text-accent-text">v{{ $version }}</span> - <x-badge tone="accent">Kanal: {{ $channel }}</x-badge> + <x-badge tone="accent">{{ __('versions.channel_badge', ['channel' => $channel]) }}</x-badge> @if ($latestTag) - <x-badge tone="neutral">neuestes Release v{{ $latestTag }}</x-badge> + <x-badge tone="neutral">{{ __('versions.latest_release_badge', ['tag' => $latestTag]) }}</x-badge> @endif </p> <p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3"> @if ($updateStatus) {{ $updateStatus }} @else - Installierte Version. „Nach Updates suchen" vergleicht sie ehrlich mit dem neuesten getaggten Release im Kanal. Aktualisierung läuft über den Deploy (Image ziehen + migrieren). + {{ __('versions.banner_default') }} @endif </p> </div> @if ($lastChecked) - <span class="shrink-0 font-mono text-[11px] text-ink-4">geprüft {{ $lastChecked }}</span> + <span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span> @endif </div> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]"> {{-- Release history BY VERSION --}} - <x-panel title="Änderungsprotokoll" :subtitle="count($releases) . ' Versionen'" :padded="false"> + <x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => count($releases)])" :padded="false"> <div class="px-4 py-4 sm:px-5"> @forelse ($releases as $rel) <div class="flex gap-4 pb-6 last:pb-0"> @@ -77,8 +77,8 @@ {{-- Version + date header --}} <div class="flex flex-wrap items-center gap-2"> @if ($rel['unreleased']) - <span class="font-display text-sm font-semibold text-ink">Unreleased</span> - <x-badge tone="cyan">in Arbeit</x-badge> + <span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span> + <x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge> @else <span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span> @if ($rel['date']) @@ -108,39 +108,39 @@ </div> </div> @empty - <p class="font-mono text-[11px] text-ink-3">Kein Änderungsprotokoll gefunden.</p> + <p class="font-mono text-[11px] text-ink-3">{{ __('versions.changelog_empty') }}</p> @endforelse </div> </x-panel> {{-- Aside --}} <div class="space-y-5"> - <x-panel title="Installiert" :padded="false"> + <x-panel :title="__('versions.installed_title')" :padded="false"> <div class="space-y-3 px-4 py-4 sm:px-5"> <div class="flex items-baseline gap-2"> <span class="font-display text-2xl font-bold text-ink">v{{ $version }}</span> - <span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-online"><x-status-dot status="online" />läuft</span> + <span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-online"><x-status-dot status="online" />{{ __('versions.running') }}</span> </div> <dl class="space-y-2 font-mono text-[11px]"> - <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Build</dt><dd class="text-ink-2">{{ $build['sha'] ?? '—' }}</dd></div> - <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Branch</dt><dd class="truncate text-ink-2">{{ $build['branch'] }}</dd></div> - <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Kanal</dt><dd class="text-ink-2">{{ $channel }}</dd></div> - <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Neuestes Release</dt><dd class="text-ink-2">{{ $latestTag ? 'v'.$latestTag : '—' }}</dd></div> - <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Lizenz</dt><dd class="text-ink-2">{{ $license }}</dd></div> + <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.build') }}</dt><dd class="text-ink-2">{{ $build['sha'] ?? '—' }}</dd></div> + <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.branch') }}</dt><dd class="truncate text-ink-2">{{ $build['branch'] }}</dd></div> + <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.channel') }}</dt><dd class="text-ink-2">{{ $channel }}</dd></div> + <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.latest_release') }}</dt><dd class="text-ink-2">{{ $latestTag ? 'v'.$latestTag : '—' }}</dd></div> + <div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.license') }}</dt><dd class="text-ink-2">{{ $license }}</dd></div> </dl> </div> </x-panel> - <x-panel title="Aktualisierung" :padded="false"> + <x-panel :title="__('versions.update_title')" :padded="false"> <div class="space-y-3 px-4 py-4 sm:px-5"> - <p class="font-mono text-[11px] leading-relaxed text-ink-3">Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:</p> + <p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ __('versions.update_hint') }}</p> <pre class="overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d artisan migrate --force</pre> </div> </x-panel> - <x-panel title="Projekt" :padded="false"> + <x-panel :title="__('versions.project_title')" :padded="false"> <div class="space-y-3 px-4 py-4 sm:px-5"> <a href="{{ $repository }}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-accent/30"> <span class="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-raised text-ink-3"><x-icon name="git-branch" class="h-4 w-4" /></span> @@ -150,7 +150,7 @@ artisan migrate --force</pre> </span> <x-icon name="chevron-left" class="h-4 w-4 shrink-0 rotate-180 text-ink-4" /> </a> - <p class="flex items-center gap-2 font-mono text-[11px] text-ink-3"><x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-cyan" /> Open Core · {{ $license }}</p> + <p class="flex items-center gap-2 font-mono text-[11px] text-ink-3"><x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-cyan" /> {{ __('versions.open_core', ['license' => $license]) }}</p> </div> </x-panel> </div> diff --git a/routes/web.php b/routes/web.php index a6d8160..cf531f7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ <?php use App\Http\Middleware\EnsureSecurityOnboarded; +use App\Http\Middleware\SetLocale; use App\Livewire\Audit; use App\Livewire\Auth; use App\Livewire\Dashboard; @@ -10,9 +11,23 @@ use App\Livewire\Services; use App\Livewire\Settings; use App\Livewire\System; use App\Livewire\Versions; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth as AuthFacade; use Illuminate\Support\Facades\Route; +// Language switch (public — also usable from the login page). Persists to the signed-in +// user and the session; only known locales (R16) are accepted, then we return back. +Route::get('/locale/{locale}', function (Request $request, string $locale) { + if (in_array($locale, SetLocale::SUPPORTED, true)) { + $request->session()->put('locale', $locale); + if ($user = $request->user()) { + $user->forceFill(['locale' => $locale])->save(); + } + } + + return redirect()->back(); +})->name('locale.set'); + Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); diff --git a/rules.md b/rules.md index 9bf2fa1..744f640 100644 --- a/rules.md +++ b/rules.md @@ -258,22 +258,24 @@ npm run build # ❌ run node inside the vite/app container --- -## R9 — German UI copy, no emoji +## R9 — UI copy register: terse, operational, no emoji **Why.** Console/status-page register: terse, operational, factual. Status is communicated -by **color/dots/pills**, never emoji. Technical identifiers stay native. +by **color/dots/pills**, never emoji. Technical identifiers stay native. German is the +default language, but every visible string is **localized** via lang files — see **R16** +(no hard-coded copy in views/components/services). ✅ **Correct** ```blade -<x-status-pill status="online">Online</x-status-pill> +<x-status-pill status="online">{{ __('common.online') }}</x-status-pill> <span class="font-mono">nginx.service</span> -<button>Dienst neu starten</button> +<button>{{ __('services.restart') }}</button> ``` ❌ **Forbidden** ```blade -<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}} -<button>Restart service</button> {{-- ❌ English UI copy --}} +<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}} +<button>Dienst neu starten</button> {{-- ❌ hard-coded copy (must be __('…'), R16) --}} ``` Keep native technical tokens as-is: `nginx.service`, `systemd`, `chmod 600`, `SSH`, `2FA`. @@ -452,6 +454,51 @@ Marking a task done while Codex still reports an error or a security issue. --- +## R16 — Every UI string is localized (German + English) + +**Why.** Clusev ships in **two languages, user-selectable** (DE default, EN). There must be +**no hard-coded user-facing copy** anywhere — every visible string (views, Livewire components, +service messages, notifications, validation, modal copy) goes through Laravel localization so a +new string is never English-only or German-only. + +**Structure (mandatory).** Translations live in `lang/<code>/<group>.php` returning a nested +array, one **group per feature area** mirroring the view tree, plus a shared `common` group: + +``` +lang/ + de/ common.php auth.php dashboard.php servers.php services.php files.php + audit.php settings.php system.php versions.php modals.php shell.php + en/ (same files, same keys) +``` + +- Access with `__('group.key')` / `@lang`; interpolate with `:placeholders` + (`__('servers.banned', ['ip' => $ip])`). +- **Keys are identical across `de` and `en`** — every key MUST exist in BOTH. Reuse `common.*` + for shared actions/status (`save`, `cancel`, `online`, …) instead of duplicating. +- Supported locales are declared once in `App\Http\Middleware\SetLocale::SUPPORTED`; the active + locale comes from the user's saved preference → session → `config('app.locale')`. + +✅ **Correct** +```blade +<x-btn>{{ __('common.save') }}</x-btn> +<h2>{{ __('servers.detail_title') }}</h2> +``` +```php +$this->dispatch('notify', message: __('servers.credential_saved')); +``` + +❌ **Forbidden** +```blade +<x-btn>Speichern</x-btn> {{-- ❌ hard-coded — add a key to lang/{de,en} --}} +``` +```php +$this->error = 'Server nicht gefunden.'; // ❌ use __('common.server_not_found') +``` + +Adding a string in only one language, or omitting a key from `en`/`de`, is a rule violation. + +--- + ## Appendix — Secret hygiene The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in