i18n: complete DE/EN localization (modals, shell, backend) + v0.3.0

- modals: wire 5 hard-coded views + 10 components to lang/{de,en}/modals.php
- shell: command-palette (incl. app.js cmdk(nav,actions) refactor), server-item,
  toaster fallback
- backend: new lang/{de,en}/backend.php; localize Hardening/Firewall/Fail2ban/
  Maintenance services, OsProfile, plus FleetService + CredentialVault errors
- fix: localized page <title> on Dashboard/Servers index+show (->title(), the
  Livewire-3 way; the stray title() methods were never auto-called)
- fix: topbar inline @php() was swallowed by Blade's raw-block precompiler up to a
  later @endphp -> 500 on every authed page; converted to block @php ... @endphp
- versions: localize changelog section labels via a canonical heading map
- translation-quality pass: Unlock, Add credentials, clearer auth/notify copy

Bump 0.2.1 -> 0.3.0; CHANGELOG.

Verified: Pint clean; npm build OK; R12 all routes HTTP 200 + 0 console errors in
BOTH locales (titles/nav/labels switch language); 589 used keys resolve in de+en
with full group parity; Codex review clean; adversarial DE/EN audit (0 high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.3.0
boban 2026-06-13 23:27:42 +02:00
parent 5691051ad8
commit ec1516184e
42 changed files with 500 additions and 231 deletions

View File

@ -13,6 +13,27 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.3.0] - 2026-06-13
Zweisprachige Oberfläche: die gesamte UI gibt es jetzt auf Deutsch und Englisch, frei umschaltbar pro Konto.
### Hinzugefügt
- **Sprache wählbar (Deutsch / Englisch).** Ein DE/EN-Umschalter (oben rechts, sowie auf der
Login-Seite) stellt die Sprache um; die Wahl wird pro Konto gespeichert und gilt sofort für
die gesamte Oberfläche. Ohne Wahl greift die Standardsprache (Deutsch).
- Übersetzungen liegen strukturiert unter `lang/{de,en}/<gruppe>.php` — eine Gruppe je Feature
plus geteiltes `common`, Schlüssel in beiden Sprachen identisch.
### Geändert
- **Alle sichtbaren Texte sind lokalisiert.** Views, Komponenten und Backend-Services geben
Text ausschließlich über `__('gruppe.schlüssel')` aus, nie mehr fest verdrahtet — neue,
verbindliche Regel **R16**. Native Technik-Begriffe (`nginx.service`, `SSH`, `2FA`, Ports)
bleiben unverändert.
### Entfernt
- Erläuternde „Warum …?"-Texte auf der System-Seite — die Seite zeigt nur noch Funktion und
Status.
## [0.2.1] - 2026-06-13
Verfeinerungen an 0.2.0: vollautomatisches TLS ohne sichtbare Konfiguration und einheitliche Buttons.

View File

@ -131,6 +131,6 @@ class Dashboard extends Component
'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')),
'services' => $this->svcRows,
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]);
])->title($this->title());
}
}

View File

@ -50,7 +50,7 @@ class AddSshKey extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -73,7 +73,7 @@ class AddSshKey extends ModalComponent
]);
$this->dispatch('keyChanged');
$this->dispatch('notify', message: 'SSH-Schlüssel hinzugefügt.');
$this->dispatch('notify', message: __('modals.add_ssh_key.notify_added'));
$this->closeModal();
}

View File

@ -16,11 +16,11 @@ use LivewireUI\Modal\ModalComponent;
*/
class ConfirmAction extends ModalComponent
{
public string $heading = 'Aktion bestätigen';
public string $heading = '';
public string $body = '';
public string $confirmLabel = 'Bestätigen';
public string $confirmLabel = '';
public bool $danger = false;
@ -40,14 +40,29 @@ class ConfirmAction extends ModalComponent
public array $params = [];
/** Toast shown on confirm. Pass '' to defer the notification to the handler
* (e.g. when the real outcome is only known after a remote command runs). */
public string $notify = 'Aktion ausgeführt.';
* (e.g. when the real outcome is only known after a remote command runs);
* leave null to fall back to the generic default toast. */
public ?string $notify = null;
public static function modalMaxWidth(): string
{
return 'md';
}
/** Apply localized fallbacks for any copy the opener did not pass. */
public function boot(): void
{
if ($this->heading === '') {
$this->heading = __('modals.confirm_action.default_heading');
}
if ($this->confirmLabel === '') {
$this->confirmLabel = __('common.confirm');
}
if ($this->notify === null) {
$this->notify = __('modals.confirm_action.default_notify');
}
}
public function confirm(): void
{
if ($this->auditAction !== '') {

View File

@ -54,7 +54,7 @@ class CreateServer extends ModalComponent
$numericDotted = preg_match('/^[0-9.]+$/', $value) === 1;
$isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1;
if (! $isIp && ! $isHost) {
$fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.');
$fail(__('modals.create_server.validation_ip_or_host'));
}
}],
'sshPort' => ['required', 'integer', 'min:1', 'max:65535'],
@ -72,13 +72,13 @@ class CreateServer extends ModalComponent
protected function validationAttributes(): array
{
return [
'name' => 'Name',
'ip' => 'IP/Host',
'sshPort' => 'SSH-Port',
'username' => 'Benutzer',
'authType' => 'Authentifizierung',
'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort',
'credentialName' => 'Zugangs-Name',
'name' => __('modals.create_server.attr_name'),
'ip' => __('modals.create_server.attr_ip'),
'sshPort' => __('modals.create_server.attr_ssh_port'),
'username' => __('modals.create_server.attr_user'),
'authType' => __('modals.create_server.attr_auth'),
'secret' => $this->authType === 'key' ? __('modals.create_server.attr_secret_key') : __('modals.create_server.attr_secret_password'),
'credentialName' => __('modals.create_server.attr_credential_name'),
];
}
@ -117,7 +117,7 @@ class CreateServer extends ModalComponent
});
$this->dispatch('serverCreated');
$this->dispatch('notify', message: 'Server hinzugefügt.');
$this->dispatch('notify', message: __('modals.create_server.notify_added'));
$this->closeModal();
}

View File

@ -50,17 +50,17 @@ class EditCredential extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
if (trim($this->username) === '' || trim($this->secret) === '') {
$this->error = 'Benutzer und Passwort/Schlüssel sind erforderlich.';
$this->error = __('modals.edit_credential.error_required');
return;
}
if (mb_strlen(trim($this->name)) > 120) {
$this->error = 'Name darf höchstens 120 Zeichen lang sein.';
$this->error = __('modals.edit_credential.error_name_too_long');
return;
}
@ -84,7 +84,7 @@ class EditCredential extends ModalComponent
]);
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: 'Zugangsdaten gespeichert.');
$this->dispatch('notify', message: __('modals.edit_credential.notify_saved'));
$this->closeModal();
}

View File

@ -48,7 +48,7 @@ class Fail2banBan extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -62,7 +62,7 @@ class Fail2banBan extends ModalComponent
}
if (! $res['ok']) {
$this->error = $res['output'] !== '' ? $res['output'] : 'Sperren fehlgeschlagen.';
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.fail2ban_ban.error_ban_failed');
return;
}
@ -77,7 +77,7 @@ class Fail2banBan extends ModalComponent
]);
$this->dispatch('fail2banChanged');
$this->dispatch('notify', message: 'IP '.trim($this->ip).' gesperrt.');
$this->dispatch('notify', message: __('modals.fail2ban_ban.notify_banned', ['ip' => trim($this->ip)]));
$this->closeModal();
}

View File

@ -39,7 +39,7 @@ class Fail2banConfig extends ModalComponent
$server = Server::find($serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -67,7 +67,7 @@ class Fail2banConfig extends ModalComponent
// Never overwrite the remote policy with defaults the operator never saw —
// saving is only allowed once the current values were read successfully.
if (! $this->loaded) {
$this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.';
$this->error = __('modals.fail2ban_config.error_read_locked');
return;
}
@ -83,17 +83,17 @@ class Fail2banConfig extends ModalComponent
'maxretry' => ['required', 'integer', 'min:1', 'max:100'],
'findtime' => $duration,
], [
'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).',
'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).',
'bantime.regex' => __('modals.fail2ban_config.error_bantime_invalid'),
'findtime.regex' => __('modals.fail2ban_config.error_findtime_invalid'),
], [
'bantime' => 'Sperrdauer',
'maxretry' => 'Max. Fehlversuche',
'findtime' => 'Zeitfenster',
'bantime' => __('modals.fail2ban_config.attr_bantime'),
'maxretry' => __('modals.fail2ban_config.attr_maxretry'),
'findtime' => __('modals.fail2ban_config.attr_findtime'),
]);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -114,7 +114,7 @@ class Fail2banConfig extends ModalComponent
}
if (! $result['ok']) {
$this->error = 'Speichern fehlgeschlagen. '.$result['output'];
$this->error = __('modals.fail2ban_config.error_save_failed', ['output' => $result['output']]);
return;
}
@ -129,7 +129,7 @@ class Fail2banConfig extends ModalComponent
]);
$this->dispatch('hardeningApplied');
$this->dispatch('notify', message: 'fail2ban konfiguriert.');
$this->dispatch('notify', message: __('modals.fail2ban_config.notify_saved'));
$this->closeModal();
}

View File

@ -47,7 +47,7 @@ class FileEditor extends ModalComponent
{
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
$this->loaded = true;
return;
@ -71,7 +71,7 @@ class FileEditor extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -94,7 +94,7 @@ class FileEditor extends ModalComponent
]);
$this->dispatch('fileSaved');
$this->dispatch('notify', message: '„'.$this->name.'" gespeichert.');
$this->dispatch('notify', message: __('modals.file_editor.notify_saved', ['name' => $this->name]));
$this->closeModal();
}

View File

@ -48,7 +48,7 @@ class FirewallRule extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -64,7 +64,7 @@ class FirewallRule extends ModalComponent
}
if (! $res['ok']) {
$this->error = $res['output'] !== '' ? $res['output'] : 'Regel konnte nicht hinzugefügt werden.';
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.firewall_rule.error_add_failed');
return;
}
@ -79,7 +79,7 @@ class FirewallRule extends ModalComponent
]);
$this->dispatch('firewallChanged');
$this->dispatch('notify', message: 'Firewall-Regel hinzugefügt.');
$this->dispatch('notify', message: __('modals.firewall_rule.notify_added'));
$this->closeModal();
}
@ -87,7 +87,10 @@ class FirewallRule extends ModalComponent
private function summary(?int $port): string
{
if ($this->tool === 'firewalld') {
return 'Port '.$port.'/'.($this->proto === 'udp' ? 'udp' : 'tcp').' geöffnet';
return __('modals.firewall_rule.summary_port_opened', [
'port' => (string) $port,
'proto' => $this->proto === 'udp' ? 'udp' : 'tcp',
]);
}
$parts = [$this->action];
@ -95,7 +98,7 @@ class FirewallRule extends ModalComponent
$parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : '');
}
if (trim($this->from) !== '') {
$parts[] = 'von '.trim($this->from);
$parts[] = __('modals.firewall_rule.summary_from', ['source' => trim($this->from)]);
}
return implode(' ', $parts);

View File

@ -47,7 +47,7 @@ class HardeningAction extends ModalComponent
$server = Server::find($serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -74,7 +74,7 @@ class HardeningAction extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -92,22 +92,22 @@ class HardeningAction extends ModalComponent
$this->done = true;
$this->ok = $result['ok'];
// Clean result only — no raw command output on success; a short reason on failure.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200);
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 200);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' '.__('modals.hardening_action.audit_failed_suffix')),
'ip' => request()->ip(),
]);
if ($this->ok) {
$this->dispatch('hardeningApplied');
$this->dispatch('notify', message: $this->heading.' angewendet.');
$this->dispatch('notify', message: __('modals.hardening_action.notify_applied', ['action' => $this->heading]));
} else {
$this->dispatch('notify', message: $this->heading.' fehlgeschlagen.');
$this->dispatch('notify', message: __('modals.hardening_action.notify_failed', ['action' => $this->heading]));
}
}

View File

@ -46,7 +46,7 @@ class SystemUpdate extends ModalComponent
$server = Server::find($serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -75,7 +75,7 @@ class SystemUpdate extends ModalComponent
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -86,7 +86,7 @@ class SystemUpdate extends ModalComponent
$this->done = true;
$this->ok = false;
$this->output = $e->getMessage();
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
return;
}
@ -102,15 +102,15 @@ class SystemUpdate extends ModalComponent
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'system.package_upgrade',
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
'target' => $server->name.($this->ok ? '' : ' '.__('modals.system_update.audit_failed_suffix')),
'ip' => request()->ip(),
]);
if ($this->ok) {
$this->pending = 0;
$this->dispatch('notify', message: 'System aktualisiert.');
$this->dispatch('notify', message: __('modals.system_update.notify_updated'));
} else {
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
}
}

View File

@ -52,6 +52,6 @@ class Index extends Component
'online' => (int) ($counts['online'] ?? 0),
'warning' => (int) ($counts['warning'] ?? 0),
'offline' => (int) ($counts['offline'] ?? 0),
]);
])->title($this->title());
}
}

View File

@ -488,6 +488,6 @@ class Show extends Component
public function render()
{
return view('livewire.servers.show');
return view('livewire.servers.show')->title($this->title());
}
}

View File

@ -112,7 +112,7 @@ class Fail2banService
$res = $this->fleet->runPrivileged($server, $cmd);
if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.');
throw new RuntimeException(__('backend.fail2ban_config_read_failed'));
}
return $this->parseDefaults($res['output']);
@ -184,7 +184,7 @@ class Fail2banService
public function unban(Server $server, string $jail, string $ip): array
{
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
return ['ok' => false, 'output' => __('backend.invalid_input')];
}
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
@ -200,7 +200,7 @@ class Fail2banService
public function ban(Server $server, string $jail, string $ip): array
{
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
return ['ok' => false, 'output' => __('backend.invalid_input')];
}
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
@ -213,7 +213,7 @@ class Fail2banService
$loopback = $this->sameIp($ip, '::1')
|| (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.'));
if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) {
return ['ok' => false, 'output' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).'];
return ['ok' => false, 'output' => __('backend.ip_belongs_to_clusev')];
}
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip);
@ -226,7 +226,7 @@ class Fail2banService
{
$ip = trim($ip);
if (! $this->validIpOrCidr($ip)) {
return ['ok' => false, 'output' => 'Ungültige IP-Adresse oder CIDR.'];
return ['ok' => false, 'output' => __('backend.invalid_ip_or_cidr')];
}
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
@ -234,7 +234,7 @@ class Fail2banService
$cfg = $this->readConfig($server);
if (in_array($ip, $cfg['ignoreip'], true)) {
return ['ok' => true, 'noop' => true, 'output' => 'Bereits auf der Whitelist.'];
return ['ok' => true, 'noop' => true, 'output' => __('backend.already_whitelisted')];
}
$list = $cfg['ignoreip'];
$list[] = $ip;
@ -250,12 +250,12 @@ class Fail2banService
return ['ok' => false, 'output' => $reason];
}
if (in_array($ip, self::LOOPBACK, true)) {
return ['ok' => false, 'output' => 'Loopback bleibt immer auf der Whitelist.'];
return ['ok' => false, 'output' => __('backend.loopback_stays_whitelisted')];
}
$cfg = $this->readConfig($server);
if (! in_array($ip, $cfg['ignoreip'], true)) {
return ['ok' => true, 'noop' => true, 'output' => 'Nicht auf der Whitelist.'];
return ['ok' => true, 'noop' => true, 'output' => __('backend.not_on_whitelist')];
}
$list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip));

View File

@ -115,7 +115,7 @@ class FirewallService
// Rule mutation is ufw-only this release; firewalld is read-only.
if ($os->firewallTool === 'firewalld') {
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
return ['ok' => false, 'output' => __('backend.firewalld_read_only')];
}
$action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow';
@ -123,13 +123,13 @@ class FirewallService
$from = ($from !== null && trim($from) !== '') ? trim($from) : null;
if ($port !== null && ($port < 1 || $port > 65535)) {
return ['ok' => false, 'output' => 'Ungültiger Port (165535).'];
return ['ok' => false, 'output' => __('backend.invalid_port')];
}
if ($from !== null && ! $this->validIpOrCidr($from)) {
return ['ok' => false, 'output' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.'];
return ['ok' => false, 'output' => __('backend.invalid_source')];
}
if ($port === null && $from === null) {
return ['ok' => false, 'output' => 'Port oder Quelle ist erforderlich.'];
return ['ok' => false, 'output' => __('backend.port_or_source_required')];
}
// LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever
@ -140,15 +140,14 @@ class FirewallService
$sshPort = $this->sshPort($server);
if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true)
&& ($port === null || $port === $sshPort)) {
return ['ok' => false, 'output' => ($port === null
? 'Eine '.$action.'-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. '
: 'Eine '.$action.'-Regel auf den SSH-Port ('.$sshPort.') ist nicht erlaubt (Aussperrgefahr). ')
.'Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.'];
return ['ok' => false, 'output' => $port === null
? __('backend.deny_without_port_blocks_ssh', ['action' => $action])
: __('backend.deny_on_ssh_port_blocks_ssh', ['action' => $action, 'port' => $sshPort])];
}
$tool = FirewallTool::for($os);
if ($tool->isFirewalld() && $port === null) {
return ['ok' => false, 'output' => 'firewalld: Bitte einen Port angeben.'];
return ['ok' => false, 'output' => __('backend.firewalld_port_required')];
}
$res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60);
@ -172,7 +171,7 @@ class FirewallService
// Rule mutation is ufw-only this release; firewalld is read-only.
if ($os->firewallTool === 'firewalld') {
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
return ['ok' => false, 'output' => __('backend.firewalld_read_only')];
}
$tool = FirewallTool::for($os);
@ -185,13 +184,13 @@ class FirewallService
// also race-free (no rule number to shift).
$status = $this->status($server);
if (! empty($status['readError'])) {
return ['ok' => false, 'output' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.'];
return ['ok' => false, 'output' => __('backend.firewall_status_read_failed')];
}
$known = array_column($status['rules'], 'spec');
if (! in_array($spec, $known, true)) {
// The rule vanished between confirm and apply — report not_found so the
// caller does NOT audit a deletion that never happened.
return ['ok' => true, 'notFound' => true, 'output' => 'Regel existiert nicht mehr.'];
return ['ok' => true, 'notFound' => true, 'output' => __('backend.rule_gone')];
}
// LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open.
if ($this->ufwSpecOpensSsh($spec, $sshPort)) {
@ -245,8 +244,7 @@ class FirewallService
/** German refusal shown when a delete would sever Clusev's own SSH access. */
private function sshGuardMessage(int $port): string
{
return "Diese Regel hält den SSH-Zugang (Port {$port}) offen und kann nicht entfernt werden — "
.'sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.';
return __('backend.ssh_rule_guard', ['port' => $port]);
}
// NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT

View File

@ -395,7 +395,7 @@ class FleetService
try {
$content = $sftp->get('.ssh/authorized_keys');
if (trim($content) === '') {
throw new RuntimeException('authorized_keys leer oder nicht lesbar.');
throw new RuntimeException(__('backend.authorized_keys_unreadable'));
}
$lines = array_values(array_filter(
@ -442,7 +442,7 @@ class FleetService
try {
if (! $sftp->delete($path)) {
throw new RuntimeException('Loeschen fehlgeschlagen (Rechte?).');
throw new RuntimeException(__('backend.delete_failed_perms'));
}
} finally {
$sftp->disconnect();
@ -456,7 +456,7 @@ class FleetService
try {
if ($sftp->size($path) > 50_000_000) {
throw new RuntimeException('Datei zu groß für den Download (>50 MB).');
throw new RuntimeException(__('backend.file_too_large_download'));
}
return (string) $sftp->get($path);
@ -491,7 +491,7 @@ class FleetService
try {
if (! $sftp->put($path, $content)) {
throw new RuntimeException('Speichern fehlgeschlagen (Rechte?).');
throw new RuntimeException(__('backend.save_failed_perms'));
}
} finally {
$sftp->disconnect();
@ -504,7 +504,7 @@ class FleetService
try {
if (! $sftp->putFromFile($remotePath, $localPath)) {
throw new RuntimeException('Upload fehlgeschlagen (Rechte?).');
throw new RuntimeException(__('backend.upload_failed_perms'));
}
} finally {
$sftp->disconnect();
@ -748,7 +748,7 @@ class FleetService
}
if ($out === []) {
$out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.'];
$out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')];
}
return $out;

View File

@ -67,7 +67,7 @@ class HardeningService
$res = $this->fleet->runPrivileged($server, $cmd);
if (! $res['ok']) {
throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
throw new RuntimeException(__('backend.hardening_status_read_failed'));
}
return $this->parseState($res['output'], $os);
@ -99,7 +99,7 @@ class HardeningService
$upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false];
$detail = fn (string $name, array $st): string => $name.' · '
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
.(! $st['installed'] ? __('backend.item_not_installed') : ($st['active'] ? __('common.active') : __('common.inactive')));
// A package feature is only effectively ON when installed AND active —
// stale config (e.g. apt periodic = 1 after removal) must not read as secure.
$on = fn (array $st): bool => $st['installed'] && $st['active'];
@ -115,11 +115,11 @@ class HardeningService
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
return [
$row('ssh_root', 'SSH-Root-Login', 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
$row('ssh_password', 'SSH-Passwort-Login', 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
$row('ssh_root', __('backend.label_ssh_root'), 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
$row('ssh_password', __('backend.label_ssh_password'), 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
$row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')),
$row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')),
$row('unattended', 'Automatische Updates', $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')),
$row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')),
];
}
@ -127,12 +127,12 @@ class HardeningService
public function title(string $action, bool $enable): string
{
return match ($action) {
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
'firewall' => $enable ? 'Firewall aktivieren' : 'Firewall deaktivieren',
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
'ssh_root' => $enable ? __('backend.title_ssh_root_enable') : __('backend.title_ssh_root_disable'),
'ssh_password' => $enable ? __('backend.title_ssh_password_enable') : __('backend.title_ssh_password_disable'),
'fail2ban' => $enable ? __('backend.title_fail2ban_enable') : __('backend.title_fail2ban_disable'),
'firewall' => $enable ? __('backend.title_firewall_enable') : __('backend.title_firewall_disable'),
'unattended' => $enable ? __('backend.title_unattended_enable') : __('backend.title_unattended_disable'),
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
};
}
@ -140,21 +140,21 @@ class HardeningService
{
return match ($action) {
'ssh_root' => $enable
? 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.'
: 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.',
? __('backend.desc_ssh_root_enable')
: __('backend.desc_ssh_root_disable'),
'ssh_password' => $enable
? 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).'
: 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
? __('backend.desc_ssh_password_enable')
: __('backend.desc_ssh_password_disable'),
'fail2ban' => $enable
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
: 'Stoppt fail2ban und deaktiviert den Autostart.',
? __('backend.desc_fail2ban_enable')
: __('backend.desc_fail2ban_disable'),
'firewall' => $enable
? 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.'
: 'Deaktiviert die Firewall des Servers.',
? __('backend.desc_firewall_enable')
: __('backend.desc_firewall_disable'),
'unattended' => $enable
? 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.'
: 'Schaltet die automatischen Updates ab.',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
? __('backend.desc_unattended_enable')
: __('backend.desc_unattended_disable'),
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
};
}
@ -169,7 +169,7 @@ class HardeningService
'fail2ban' => 'fail2ban',
'firewall' => 'firewall',
'unattended' => 'auto_updates',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
};
if ($reason = $os->supports($feature)) {
return ['ok' => false, 'output' => $reason];
@ -184,10 +184,10 @@ class HardeningService
// Lock-out guard: never disable password auth without a usable key path.
if ($action === 'ssh_password' && ! $enable) {
if ($server->credential?->auth_type === 'password') {
return ['ok' => false, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.'];
return ['ok' => false, 'output' => __('backend.ssh_password_self_lockout')];
}
if (! $this->hasAuthorizedKey($server)) {
return ['ok' => false, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).'];
return ['ok' => false, 'output' => __('backend.ssh_password_no_key')];
}
}
@ -210,7 +210,7 @@ class HardeningService
? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban'
: 'systemctl disable --now fail2ban',
'unattended' => $this->autoUpdateScript($os, $pm, $enable),
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
};
}

View File

@ -50,12 +50,12 @@ final class OsProfile
public function familyLabel(): string
{
return match ($this->family) {
'debian' => 'Debian/Ubuntu',
'rhel' => 'RHEL/Fedora',
'suse' => 'openSUSE/SLES',
'arch' => 'Arch Linux',
'alpine' => 'Alpine Linux',
default => 'Unbekanntes System',
'debian' => __('backend.family_debian'),
'rhel' => __('backend.family_rhel'),
'suse' => __('backend.family_suse'),
'arch' => __('backend.family_arch'),
'alpine' => __('backend.family_alpine'),
default => __('backend.family_unknown'),
};
}
@ -63,9 +63,9 @@ final class OsProfile
public function firewallLabel(): string
{
return match ($this->firewallTool) {
'ufw' => 'Firewall (UFW)',
'firewalld' => 'Firewall (firewalld)',
default => 'Firewall',
'ufw' => __('backend.firewall_label_ufw'),
'firewalld' => __('backend.firewall_label_firewalld'),
default => __('backend.firewall_label_generic'),
};
}
@ -79,27 +79,27 @@ final class OsProfile
return match ($feature) {
'ssh' => $this->isSystemd()
? null
: 'SSH-Härtung benötigt systemd, das auf '.$this->familyLabel().' nicht erkannt wurde.',
: __('backend.support_ssh', ['family' => $this->familyLabel()]),
'services' => $this->isSystemd()
? null
: 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.',
: __('backend.support_services'),
'updates' => $this->managesPackages()
? null
: 'Paket-Updates über Clusev werden unter '.$this->familyLabel().' noch nicht unterstützt.',
: __('backend.support_updates', ['family' => $this->familyLabel()]),
'fail2ban' => ($this->managesPackages() && $this->isSystemd())
? null
: 'fail2ban wird unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
: __('backend.support_fail2ban', ['family' => $this->familyLabel()]),
'firewall' => ($this->firewallTool !== 'none' && $this->isSystemd()
&& ($this->firewallTool === 'firewalld' || $this->managesPackages() || $this->has('ufw')))
? null
: 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter '.$this->familyLabel().' nicht gefunden.',
: __('backend.support_firewall', ['family' => $this->familyLabel()]),
// apt -> unattended-upgrades; dnf -> dnf-automatic. A yum-ONLY host (e.g.
// CentOS 7) maps to the dnf manager for upgrades but has no dnf-automatic
// (it uses yum-cron), so require a real `dnf` binary for this feature.
'auto_updates' => (($this->packageManager === 'apt'
|| ($this->packageManager === 'dnf' && $this->has('dnf'))) && $this->isSystemd())
? null
: 'Automatische Updates werden unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
: __('backend.support_auto_updates', ['family' => $this->familyLabel()]),
default => null,
};
}

View File

@ -22,11 +22,11 @@ class CredentialVault
$cred = $server->credential;
if (! $cred) {
throw new RuntimeException("Kein SSH-Credential für Server {$server->name} hinterlegt.");
throw new RuntimeException(__('backend.no_ssh_credential', ['server' => $server->name]));
}
if ($cred->disabled_at !== null) {
throw new RuntimeException("SSH-Zugang für {$server->name} ist gesperrt.");
throw new RuntimeException(__('backend.ssh_access_locked', ['server' => $server->name]));
}
$secret = $cred->auth_type === 'key'

View File

@ -3,7 +3,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.2.1',
'version' => '0.3.0',
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable',

100
lang/de/backend.php Normal file
View File

@ -0,0 +1,100 @@
<?php
// Human-readable strings returned by backend services (HardeningService,
// FirewallService, Fail2banService, MaintenanceService, OsProfile) and shown in
// the UI: confirm-modal copy, guard/lockout refusals, read errors, and the
// supports()/reason sentences. Technical tokens (sshd, ufw, firewalld, ports,
// paths) stay verbatim. Reuse common.* for save/cancel/active/inactive/etc.
return [
// ── HardeningService: state labels + detail ──────────────────────────
'item_not_installed' => 'nicht installiert',
'label_ssh_root' => 'SSH-Root-Login',
'label_ssh_password' => 'SSH-Passwort-Login',
'label_auto_updates' => 'Automatische Updates',
// ── HardeningService: modal titles (per direction) ───────────────────
'title_ssh_root_enable' => 'SSH-Root-Login erlauben',
'title_ssh_root_disable' => 'SSH-Root-Login deaktivieren',
'title_ssh_password_enable' => 'SSH-Passwort-Login erlauben',
'title_ssh_password_disable' => 'SSH-Passwort-Login deaktivieren',
'title_fail2ban_enable' => 'fail2ban aktivieren',
'title_fail2ban_disable' => 'fail2ban deaktivieren',
'title_firewall_enable' => 'Firewall aktivieren',
'title_firewall_disable' => 'Firewall deaktivieren',
'title_unattended_enable' => 'Automatische Updates aktivieren',
'title_unattended_disable' => 'Automatische Updates deaktivieren',
// ── HardeningService: modal descriptions (per direction) ─────────────
'desc_ssh_root_enable' => 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.',
'desc_ssh_root_disable' => 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.',
'desc_ssh_password_enable' => 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).',
'desc_ssh_password_disable' => 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
'desc_fail2ban_enable' => 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.',
'desc_fail2ban_disable' => 'Stoppt fail2ban und deaktiviert den Autostart.',
'desc_firewall_enable' => 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.',
'desc_firewall_disable' => 'Deaktiviert die Firewall des Servers.',
'desc_unattended_enable' => 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.',
'desc_unattended_disable' => 'Schaltet die automatischen Updates ab.',
// ── HardeningService: errors + guards ────────────────────────────────
'hardening_status_read_failed' => 'Härtungs-Status konnte nicht gelesen werden.',
'ssh_password_self_lockout' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.',
'ssh_password_no_key' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',
// ── FirewallService: guards / reasons / errors ───────────────────────
'firewalld_read_only' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.',
'invalid_port' => 'Ungültiger Port (165535).',
'invalid_source' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.',
'port_or_source_required' => 'Port oder Quelle ist erforderlich.',
'deny_without_port_blocks_ssh' => 'Eine :action-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.',
'deny_on_ssh_port_blocks_ssh' => 'Eine :action-Regel auf den SSH-Port (:port) ist nicht erlaubt (Aussperrgefahr). Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.',
'firewalld_port_required' => 'firewalld: Bitte einen Port angeben.',
'firewall_status_read_failed' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.',
'rule_gone' => 'Regel existiert nicht mehr.',
'ssh_rule_guard' => 'Diese Regel hält den SSH-Zugang (Port :port) offen und kann nicht entfernt werden — sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.',
// ── Fail2banService: guards / reasons / errors ───────────────────────
'ip_belongs_to_clusev' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).',
'already_whitelisted' => 'Bereits auf der Whitelist.',
'not_on_whitelist' => 'Nicht auf der Whitelist.',
'loopback_stays_whitelisted' => 'Loopback bleibt immer auf der Whitelist.',
'invalid_input' => 'Ungültige Eingabe.',
'invalid_ip_or_cidr' => 'Ungültige IP-Adresse oder CIDR.',
'fail2ban_config_read_failed' => 'fail2ban-Konfiguration konnte nicht gelesen werden.',
// ── OsProfile: family labels ─────────────────────────────────────────
'family_debian' => 'Debian/Ubuntu',
'family_rhel' => 'RHEL/Fedora',
'family_suse' => 'openSUSE/SLES',
'family_arch' => 'Arch Linux',
'family_alpine' => 'Alpine Linux',
'family_unknown' => 'Unbekanntes System',
// ── OsProfile: firewall labels ───────────────────────────────────────
'firewall_label_ufw' => 'Firewall (UFW)',
'firewall_label_firewalld' => 'Firewall (firewalld)',
'firewall_label_generic' => 'Firewall',
// ── OsProfile: supports() reasons (:family = familyLabel()) ──────────
'support_ssh' => 'SSH-Härtung benötigt systemd, das auf :family nicht erkannt wurde.',
'support_services' => 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.',
'support_updates' => 'Paket-Updates über Clusev werden unter :family noch nicht unterstützt.',
'support_fail2ban' => 'fail2ban wird unter :family über Clusev noch nicht unterstützt.',
'support_firewall' => 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter :family nicht gefunden.',
'support_auto_updates' => 'Automatische Updates werden unter :family über Clusev noch nicht unterstützt.',
// ── FleetService: SFTP / file-manager / journal errors ───────────────
'authorized_keys_unreadable' => 'authorized_keys leer oder nicht lesbar.',
'delete_failed_perms' => 'Löschen fehlgeschlagen (Rechte?).',
'file_too_large_download' => 'Datei zu groß für den Download (>50 MB).',
'save_failed_perms' => 'Speichern fehlgeschlagen (Rechte?).',
'upload_failed_perms' => 'Upload fehlgeschlagen (Rechte?).',
'journal_needs_privileges' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.',
// ── CredentialVault: SSH credential guards (:server = name) ──────────
'no_ssh_credential' => 'Kein SSH-Credential für Server :server hinterlegt.',
'ssh_access_locked' => 'SSH-Zugang für :server ist gesperrt.',
// ── HardeningService: invalid action invariant (:action) ─────────────
'unknown_hardening' => 'Unbekannte Härtung: :action',
];

View File

@ -158,6 +158,7 @@ return [
'result_ok' => 'System aktualisiert.',
'result_failed' => 'Aktualisierung fehlgeschlagen.',
'submit' => 'Jetzt aktualisieren',
'audit_failed_suffix' => '(fehlgeschlagen)',
'notify_updated' => 'System aktualisiert.',
'notify_failed' => 'Aktualisierung fehlgeschlagen.',
],

View File

@ -25,6 +25,14 @@ return [
'in_progress' => 'in Arbeit',
'changelog_empty' => 'Kein Änderungsprotokoll gefunden.',
// Changelog section labels (Keep a Changelog subsections)
'section_added' => 'Hinzugefügt',
'section_changed' => 'Geändert',
'section_fixed' => 'Behoben',
'section_security' => 'Sicherheit',
'section_removed' => 'Entfernt',
'section_deprecated' => 'Veraltet',
// Installed panel
'installed_title' => 'Installiert',
'running' => 'läuft',

View File

@ -63,10 +63,10 @@ return [
// ── Validation / error messages ──────────────────────────────────────
'too_many_attempts' => 'Too many attempts. Please try again in :seconds seconds.',
'invalid_credentials' => 'These credentials do not match.',
'invalid_credentials' => 'These credentials are incorrect.',
'session_expired' => 'Session expired. Please sign in again.',
'invalid_code' => 'Invalid code.',
'code_mismatch' => 'Code does not match — check your authenticator apps clock.',
'code_mismatch' => 'Code does not match — check your authenticator apps time.',
// ── Validation attribute names ───────────────────────────────────────
'attr_current_password' => 'current password',

100
lang/en/backend.php Normal file
View File

@ -0,0 +1,100 @@
<?php
// Human-readable strings returned by backend services (HardeningService,
// FirewallService, Fail2banService, MaintenanceService, OsProfile) and shown in
// the UI: confirm-modal copy, guard/lockout refusals, read errors, and the
// supports()/reason sentences. Technical tokens (sshd, ufw, firewalld, ports,
// paths) stay verbatim. Reuse common.* for save/cancel/active/inactive/etc.
return [
// ── HardeningService: state labels + detail ──────────────────────────
'item_not_installed' => 'not installed',
'label_ssh_root' => 'SSH root login',
'label_ssh_password' => 'SSH password login',
'label_auto_updates' => 'Automatic updates',
// ── HardeningService: modal titles (per direction) ───────────────────
'title_ssh_root_enable' => 'Allow SSH root login',
'title_ssh_root_disable' => 'Disable SSH root login',
'title_ssh_password_enable' => 'Allow SSH password login',
'title_ssh_password_disable' => 'Disable SSH password login',
'title_fail2ban_enable' => 'Enable fail2ban',
'title_fail2ban_disable' => 'Disable fail2ban',
'title_firewall_enable' => 'Enable firewall',
'title_firewall_disable' => 'Disable firewall',
'title_unattended_enable' => 'Enable automatic updates',
'title_unattended_disable' => 'Disable automatic updates',
// ── HardeningService: modal descriptions (per direction) ─────────────
'desc_ssh_root_enable' => 'Re-allows direct root login over SSH (PermitRootLogin yes) and reloads the SSH service.',
'desc_ssh_root_disable' => 'Blocks direct root login over SSH (PermitRootLogin no) and reloads the SSH service.',
'desc_ssh_password_enable' => 'Re-allows password login (PasswordAuthentication yes).',
'desc_ssh_password_disable' => 'Login via SSH key only (PasswordAuthentication no). Only possible if a key is in place.',
'desc_fail2ban_enable' => 'Installs fail2ban (if needed) and starts the service. Repeated failed logins get banned.',
'desc_fail2ban_disable' => 'Stops fail2ban and disables autostart.',
'desc_firewall_enable' => 'Installs the firewall (if needed), opens SSH + 80/443 first, then enables it.',
'desc_firewall_disable' => 'Disables the server firewall.',
'desc_unattended_enable' => 'Installs (if needed) and enables the system\'s automatic security updates.',
'desc_unattended_disable' => 'Turns off automatic updates.',
// ── HardeningService: errors + guards ────────────────────────────────
'hardening_status_read_failed' => 'Could not read hardening status.',
'ssh_password_self_lockout' => 'Clusev connects itself via password — password login cannot be disabled, otherwise Clusev loses access. Set up SSH key access first.',
'ssh_password_no_key' => 'No SSH key in place — password login cannot be disabled (lockout risk).',
// ── FirewallService: guards / reasons / errors ───────────────────────
'firewalld_read_only' => 'Rule management for firewalld is read-only in this version — please configure it on the server.',
'invalid_port' => 'Invalid port (165535).',
'invalid_source' => 'Invalid source — IP address or CIDR expected.',
'port_or_source_required' => 'Port or source is required.',
'deny_without_port_blocks_ssh' => 'A :action rule without a port would also block SSH access and is not allowed. Use fail2ban to block individual IPs from SSH access.',
'deny_on_ssh_port_blocks_ssh' => 'A :action rule on the SSH port (:port) is not allowed (lockout risk). Use fail2ban to block individual IPs from SSH access.',
'firewalld_port_required' => 'firewalld: Please specify a port.',
'firewall_status_read_failed' => 'Could not read firewall status — rule not removed.',
'rule_gone' => 'Rule no longer exists.',
'ssh_rule_guard' => 'This rule keeps SSH access (port :port) open and cannot be removed — otherwise Clusev locks itself out. Disable the whole firewall if needed.',
// ── Fail2banService: guards / reasons / errors ───────────────────────
'ip_belongs_to_clusev' => 'This IP belongs to Clusev\'s access and cannot be banned (lockout risk).',
'already_whitelisted' => 'Already on the whitelist.',
'not_on_whitelist' => 'Not on the whitelist.',
'loopback_stays_whitelisted' => 'Loopback always stays on the whitelist.',
'invalid_input' => 'Invalid input.',
'invalid_ip_or_cidr' => 'Invalid IP address or CIDR.',
'fail2ban_config_read_failed' => 'Could not read fail2ban configuration.',
// ── OsProfile: family labels ─────────────────────────────────────────
'family_debian' => 'Debian/Ubuntu',
'family_rhel' => 'RHEL/Fedora',
'family_suse' => 'openSUSE/SLES',
'family_arch' => 'Arch Linux',
'family_alpine' => 'Alpine Linux',
'family_unknown' => 'Unknown system',
// ── OsProfile: firewall labels ───────────────────────────────────────
'firewall_label_ufw' => 'Firewall (UFW)',
'firewall_label_firewalld' => 'Firewall (firewalld)',
'firewall_label_generic' => 'Firewall',
// ── OsProfile: supports() reasons (:family = familyLabel()) ──────────
'support_ssh' => 'SSH hardening requires systemd, which was not detected on :family.',
'support_services' => 'Service control requires systemd, which was not detected here.',
'support_updates' => 'Package updates via Clusev are not yet supported on :family.',
'support_fail2ban' => 'fail2ban is not yet supported on :family via Clusev.',
'support_firewall' => 'No supported firewall (UFW or firewalld) was found on :family.',
'support_auto_updates' => 'Automatic updates are not yet supported on :family via Clusev.',
// ── FleetService: SFTP / file-manager / journal errors ───────────────
'authorized_keys_unreadable' => 'authorized_keys is empty or unreadable.',
'delete_failed_perms' => 'Delete failed (permissions?).',
'file_too_large_download' => 'File too large to download (>50 MB).',
'save_failed_perms' => 'Save failed (permissions?).',
'upload_failed_perms' => 'Upload failed (permissions?).',
'journal_needs_privileges' => 'The system journal requires elevated privileges (group adm/systemd-journal) or sudo.',
// ── CredentialVault: SSH credential guards (:server = name) ──────────
'no_ssh_credential' => 'No SSH credential stored for server :server.',
'ssh_access_locked' => 'SSH access for :server is locked.',
// ── HardeningService: invalid action invariant (:action) ─────────────
'unknown_hardening' => 'Unknown hardening action: :action',
];

View File

@ -14,7 +14,7 @@ return [
'enable' => 'Enable',
'disable' => 'Disable',
'lock' => 'Lock',
'unlock' => 'Unblock',
'unlock' => 'Unlock',
'back' => 'Back',
'retry' => 'Try again',
'confirm' => 'Confirm',

View File

@ -157,6 +157,7 @@ return [
'result_ok' => 'System updated.',
'result_failed' => 'Update failed.',
'submit' => 'Update now',
'audit_failed_suffix' => '(failed)',
'notify_updated' => 'System updated.',
'notify_failed' => 'Update failed.',
],
@ -164,6 +165,6 @@ return [
// ── Generic confirm dialog defaults (ConfirmAction) ───────────────────
'confirm_action' => [
'default_heading' => 'Confirm action',
'default_notify' => 'Action carried out.',
'default_notify' => 'Action completed.',
],
];

View File

@ -52,7 +52,7 @@ return [
'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',
'cred_deposit' => 'Add credentials',
// ── Show: specifications ──────────────────────────────────────────────
'specs_title' => 'Specifications',

View File

@ -25,6 +25,14 @@ return [
'in_progress' => 'in progress',
'changelog_empty' => 'No changelog found.',
// Changelog section labels (Keep a Changelog subsections)
'section_added' => 'Added',
'section_changed' => 'Changed',
'section_fixed' => 'Fixed',
'section_security' => 'Security',
'section_removed' => 'Removed',
'section_deprecated' => 'Deprecated',
// Installed panel
'installed_title' => 'Installed',
'running' => 'running',

View File

@ -68,31 +68,21 @@ document.addEventListener('alpine:init', () => {
}));
// ── Command palette + keyboard shortcuts ────────────────────────────
// German nav targets (mirrors the sidebar) with a leader-key hint. Leader
// mnemonics: i=dIenste, l=Log, e=Einstellungen, y=sYstem (d/s already taken).
const CMDK_NAV = [
{ label: 'Dashboard', href: '/', hint: 'g d' },
{ label: 'Server', href: '/servers', hint: 'g s' },
{ label: 'Dienste', href: '/services', hint: 'g i' },
{ label: 'Dateien', href: '/files', hint: 'g f' },
{ label: 'Audit-Log', href: '/audit', hint: 'g l' },
{ label: 'Einstellungen', href: '/settings', hint: 'g e' },
{ label: 'System', href: '/system', hint: 'g y' },
{ label: 'Version', href: '/versions', hint: 'g v' },
];
const CMDK_ACTIONS = [
{ label: 'Server hinzufügen', action: 'create-server', hint: '' },
];
// Nav + action item labels are passed in from the Blade markup (already
// translated via __()), so the palette stays in sync with the active locale.
// CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
window.Alpine.data('cmdk', () => ({
window.Alpine.data('cmdk', (nav = [], actions = []) => ({
nav,
actions,
open: false,
help: false,
query: '',
active: 0,
leader: false,
leaderTimer: null,
nav: CMDK_NAV,
init() {
// Reset overlays after an SPA navigation. The component is recreated on each
@ -112,7 +102,7 @@ document.addEventListener('alpine:init', () => {
get items() {
const q = this.query.trim().toLowerCase();
const all = [...CMDK_NAV, ...CMDK_ACTIONS];
const all = [...this.nav, ...this.actions];
return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q));
},

View File

@ -5,21 +5,36 @@
--}}
@php
$chords = [
['Strg / ⌘ K', 'Befehle öffnen'],
['/', 'Suche auf der Seite fokussieren'],
['?', 'Diese Hilfe anzeigen'],
['g d', 'Dashboard'],
['g s', 'Server'],
['g i', 'Dienste'],
['g f', 'Dateien'],
['g l', 'Audit-Log'],
['g e', 'Einstellungen'],
['g y', 'System'],
['g v', 'Version'],
['Strg / ⌘ K', __('shell.cmdk_open')],
['/', __('shell.cmdk_focus_search')],
['?', __('shell.cmdk_show_help')],
['g d', __('shell.nav_dashboard')],
['g s', __('shell.nav_servers')],
['g i', __('shell.nav_services')],
['g f', __('shell.nav_files')],
['g l', __('shell.nav_audit')],
['g e', __('shell.nav_settings')],
['g y', __('shell.nav_system')],
['g v', __('shell.nav_versions')],
];
$nav = [
['label' => __('shell.nav_dashboard'), 'href' => '/', 'hint' => 'g d'],
['label' => __('shell.nav_servers'), 'href' => '/servers', 'hint' => 'g s'],
['label' => __('shell.nav_services'), 'href' => '/services', 'hint' => 'g i'],
['label' => __('shell.nav_files'), 'href' => '/files', 'hint' => 'g f'],
['label' => __('shell.nav_audit'), 'href' => '/audit', 'hint' => 'g l'],
['label' => __('shell.nav_settings'), 'href' => '/settings', 'hint' => 'g e'],
['label' => __('shell.nav_system'), 'href' => '/system', 'hint' => 'g y'],
['label' => __('shell.nav_versions'), 'href' => '/versions', 'hint' => 'g v'],
];
$actions = [
['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''],
];
$kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2';
@endphp
<div x-data="cmdk"
<div x-data="cmdk(@js($nav), @js($actions))"
@keydown.window="onKey($event)"
@keydown.escape.window="close()"
@cmdk-open.window="toggle(true)">
@ -33,13 +48,13 @@
<div class="flex items-center gap-2.5 border-b border-line px-4">
<x-icon name="search" class="h-4 w-4 shrink-0 text-ink-3" />
<input x-ref="input" x-model="query" type="text" autocomplete="off" spellcheck="false"
placeholder="Befehl oder Seite…"
placeholder="{{ __('shell.cmdk_placeholder') }}"
@input="active = 0"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="run()"
class="h-12 w-full bg-transparent font-mono text-sm text-ink placeholder:text-ink-4 focus:outline-none" />
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">Esc</span>
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('shell.cmdk_esc') }}</span>
</div>
<ul class="max-h-80 overflow-y-auto py-1.5">
@ -55,12 +70,12 @@
</button>
</li>
</template>
<li x-show="items.length === 0" class="px-4 py-3 font-mono text-[11px] text-ink-4">Kein Treffer.</li>
<li x-show="items.length === 0" class="px-4 py-3 font-mono text-[11px] text-ink-4">{{ __('shell.cmdk_no_match') }}</li>
</ul>
<div class="flex items-center justify-between border-t border-line px-4 py-2 font-mono text-[10px] text-ink-4">
<span class="flex items-center gap-1.5"><x-icon name="corner-down-left" class="h-3 w-3" /> öffnen</span>
<button type="button" @click="open = false; help = true" class="hover:text-ink-2">? Tastenkürzel</button>
<span class="flex items-center gap-1.5"><x-icon name="corner-down-left" class="h-3 w-3" /> {{ __('shell.cmdk_open_hint') }}</span>
<button type="button" @click="open = false; help = true" class="hover:text-ink-2">{{ __('shell.cmdk_shortcuts_hint') }}</button>
</div>
</div>
</div>
@ -71,8 +86,8 @@
<div x-show="help" x-transition class="relative w-full max-w-md overflow-hidden rounded-lg border border-line bg-surface shadow-pop">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
<x-icon name="command" class="h-4 w-4 shrink-0 text-accent-text" />
<h2 class="font-display text-sm font-semibold text-ink">Tastenkürzel</h2>
<button type="button" @click="help = false" class="ml-auto text-ink-4 hover:text-ink" aria-label="Schließen">
<h2 class="font-display text-sm font-semibold text-ink">{{ __('shell.shortcuts_heading') }}</h2>
<button type="button" @click="help = false" class="ml-auto text-ink-4 hover:text-ink" aria-label="{{ __('common.close') }}">
<x-icon name="x" class="h-4 w-4" />
</button>
</div>

View File

@ -1,6 +1,6 @@
@props(['name', 'ip', 'status' => 'online', 'cpu' => 0, 'mem' => 0, 'os' => null])
@php
$label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$status] ?? ucfirst($status);
$label = ['online' => __('common.online'), 'warning' => __('common.warning'), 'offline' => __('common.offline')][$status] ?? ucfirst($status);
@endphp
<a href="#" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-line-strong">
<x-status-dot :status="$status" :ping="$status === 'online'" />

View File

@ -1,5 +1,9 @@
@props(['title' => null])
@php($title ??= __('shell.title_default'))
@php
// Block form (not inline @php(...)): an inline @php here would be swallowed by
// Blade's raw-block precompiler up to the @endphp further down this file.
$title ??= __('shell.title_default');
@endphp
<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"

View File

@ -29,7 +29,7 @@
<div
x-data="{ toasts: [] }"
x-on:notify.window="
const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? 'Erledigt';
const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? @js(__('shell.toast_done'));
const id = (window.crypto?.randomUUID?.() ?? String(Date.now() + Math.random()));
toasts.push({ id, msg });
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);

View File

@ -4,10 +4,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">fail2ban konfigurieren</h2>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.fail2ban_config.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">
Legt fest, wann fail2ban auf {{ $serverName ?: 'diesem Server' }} eine IP sperrt und wie lange.
Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre.
{{ __('modals.fail2ban_config.subtitle', ['server' => $serverName ?: __('modals.fail2ban_config.this_server')]) }}
</p>
</div>
</div>
@ -22,22 +21,22 @@
@if ($loaded)
<div class="mt-5 space-y-4">
<div>
<label for="f2b-bantime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Sperrdauer</label>
<input id="f2b-bantime" type="text" maxlength="40" wire:model="bantime" placeholder="z. B. 600, 10m, 1h, -1 = dauerhaft"
<label for="f2b-bantime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.fail2ban_config.bantime_label') }}</label>
<input id="f2b-bantime" type="text" maxlength="40" wire:model="bantime" placeholder="{{ __('modals.fail2ban_config.bantime_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('bantime')<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 for="f2b-maxretry" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Max. Fehlversuche</label>
<label for="f2b-maxretry" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.fail2ban_config.maxretry_label') }}</label>
<input id="f2b-maxretry" type="number" min="1" max="100" wire:model="maxretry"
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('maxretry')<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 for="f2b-findtime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zeitfenster</label>
<input id="f2b-findtime" type="text" maxlength="40" wire:model="findtime" placeholder="z. B. 600, 10m, 1h"
<label for="f2b-findtime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.fail2ban_config.findtime_label') }}</label>
<input id="f2b-findtime" type="text" maxlength="40" wire:model="findtime" placeholder="{{ __('modals.fail2ban_config.findtime_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('findtime')<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>
@ -45,12 +44,12 @@
@endif
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ $loaded ? 'Abbrechen' : 'Schließen' }}</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ $loaded ? __('common.cancel') : __('common.close') }}</x-btn>
@if ($loaded)
<x-btn variant="accent" 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>
@endif
</div>

View File

@ -15,9 +15,9 @@
@elseif ($error)
<p class="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" />{{ $error }}</p>
@elseif ($tooBig)
<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" />Datei zu groß zum Bearbeiten (über 256 KB) bitte herunterladen.</p>
<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" />{{ __('modals.file_editor.too_big') }}</p>
@elseif ($binary)
<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" />Binärdatei kann nicht im Editor angezeigt werden.</p>
<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" />{{ __('modals.file_editor.binary') }}</p>
@else
<textarea wire:model="content" rows="14" spellcheck="false"
class="w-full resize-y rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2 focus:border-accent/40 focus:outline-none"></textarea>
@ -25,11 +25,11 @@
</div>
<div class="mt-5 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
@if ($loaded && ! $error && ! $tooBig && ! $binary)
<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>
Speichern
{{ __('common.save') }}
</x-btn>
@endif
</div>

View File

@ -9,12 +9,12 @@
<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">Firewall-Regel hinzufügen</h2>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.firewall_rule.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">
@if ($tool === 'firewalld')
Öffnet einen Port in der Standard-Zone von firewalld.
{{ __('modals.firewall_rule.subtitle_firewalld') }}
@else
Neue UFW-Regel. Quelle leer lassen = von überall.
{{ __('modals.firewall_rule.subtitle_ufw') }}
@endif
</p>
</div>
@ -30,28 +30,28 @@
<div class="mt-4 space-y-3">
@if ($tool !== 'firewalld')
<div>
<label class="{{ $label }}">Aktion</label>
<label class="{{ $label }}">{{ __('modals.firewall_rule.action_label') }}</label>
<select wire:model="action" class="{{ $select }}">
<option value="allow">Erlauben (allow)</option>
<option value="deny">Verwerfen (deny)</option>
<option value="reject">Ablehnen (reject)</option>
<option value="limit">Begrenzen (limit)</option>
<option value="allow">{{ __('modals.firewall_rule.action_allow') }}</option>
<option value="deny">{{ __('modals.firewall_rule.action_deny') }}</option>
<option value="reject">{{ __('modals.firewall_rule.action_reject') }}</option>
<option value="limit">{{ __('modals.firewall_rule.action_limit') }}</option>
</select>
</div>
@endif
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="{{ $label }}">Port {{ $tool === 'firewalld' ? '' : '(optional)' }}</label>
<input wire:model="port" type="number" min="1" max="65535" placeholder="z. B. 8080" class="{{ $field }}" />
<label class="{{ $label }}">{{ __('modals.firewall_rule.port_label') }} {{ $tool === 'firewalld' ? '' : __('modals.firewall_rule.port_optional') }}</label>
<input wire:model="port" type="number" min="1" max="65535" placeholder="{{ __('modals.firewall_rule.port_placeholder') }}" class="{{ $field }}" />
</div>
<div>
<label class="{{ $label }}">Protokoll</label>
<label class="{{ $label }}">{{ __('modals.firewall_rule.proto_label') }}</label>
<select wire:model="proto" class="{{ $select }}">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
@if ($tool !== 'firewalld')
<option value="any">Beide</option>
<option value="any">{{ __('modals.firewall_rule.proto_both') }}</option>
@endif
</select>
</div>
@ -59,18 +59,18 @@
@if ($tool !== 'firewalld')
<div>
<label class="{{ $label }}">Quelle (optional)</label>
<input wire:model="from" type="text" placeholder="IP oder CIDR, z. B. 10.0.0.0/8" class="{{ $field }}" />
<label class="{{ $label }}">{{ __('modals.firewall_rule.source_label') }}</label>
<input wire:model="from" type="text" placeholder="{{ __('modals.firewall_rule.source_placeholder') }}" class="{{ $field }}" />
</div>
@endif
</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>

View File

@ -4,7 +4,7 @@
<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">{{ $heading ?: 'Härtung anwenden' }}</h2>
<h2 class="font-display text-base font-semibold text-ink">{{ $heading ?: __('modals.hardening_action.title_fallback') }}</h2>
@if ($description)
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ $description }}</p>
@endif
@ -24,7 +24,7 @@
])>
<x-icon :name="$ok ? 'activity' : 'alert'" @class(['mt-0.5 h-4 w-4 shrink-0', 'text-online' => $ok, 'text-offline' => ! $ok]) />
<div class="min-w-0">
<p @class(['text-sm', 'text-online' => $ok, 'text-offline' => ! $ok])>{{ $ok ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.' }}</p>
<p @class(['text-sm', 'text-online' => $ok, 'text-offline' => ! $ok])>{{ $ok ? __('modals.hardening_action.result_ok') : __('modals.hardening_action.result_failed') }}</p>
@if (! $ok && $output !== '')
<p class="mt-1 break-words font-mono text-[11px] text-ink-3">{{ $output }}</p>
@endif
@ -34,14 +34,14 @@
<div class="mt-6 flex items-center justify-end gap-2">
@if ($done)
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
@else
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
@unless ($error)
<x-btn variant="accent" wire:click="apply" wire:target="apply" wire:loading.attr="disabled">
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="apply" />
<svg wire:loading wire:target="apply" 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>
Anwenden
{{ __('common.apply') }}
</x-btn>
@endunless
@endif

View File

@ -4,10 +4,9 @@
<x-icon name="rotate" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">System-Updates</h2>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.system_update.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">
Aktualisiert die installierten Pakete auf {{ $serverName ?: 'diesem Server' }} über die
Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen nur verfügbare Updates eingespielt.
{{ __('modals.system_update.subtitle', ['server' => $serverName ?: __('modals.system_update.this_server')]) }}
</p>
</div>
</div>
@ -20,7 +19,7 @@
@elseif (! $supported)
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-3.5 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ $unsupportedReason ?: 'Auf diesem System nicht verfügbar.' }}</p>
<p class="text-sm text-ink-2">{{ $unsupportedReason ?: __('modals.system_update.unsupported_fallback') }}</p>
</div>
@else
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
@ -35,18 +34,18 @@
<div class="min-w-0">
<p class="font-display text-sm font-semibold text-ink">
@if ($pending === null)
Update-Anzahl unbekannt
{{ __('modals.system_update.count_unknown') }}
@elseif ($pending === 1)
1 Paket-Update verfügbar
{{ __('modals.system_update.count_one') }}
@else
{{ $pending }} Paket-Updates verfügbar
{{ __('modals.system_update.count_many', ['count' => $pending]) }}
@endif
</p>
<p class="mt-1 font-mono text-[11px] text-ink-3">
@if ($pending === null)
Konnte nicht ermittelt werden Aktualisierung trotzdem möglich.
{{ __('modals.system_update.hint_unknown') }}
@else
{{ $pending > 0 ? 'Aktualisierung empfohlen.' : 'Das System ist auf dem aktuellen Stand.' }}
{{ $pending > 0 ? __('modals.system_update.hint_recommended') : __('modals.system_update.hint_up_to_date') }}
@endif
</p>
</div>
@ -64,7 +63,7 @@
'text-offline' => ! $ok,
])>
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
{{ $ok ? 'System aktualisiert.' : 'Aktualisierung fehlgeschlagen.' }}
{{ $ok ? __('modals.system_update.result_ok') : __('modals.system_update.result_failed') }}
</p>
@if (! $ok && $output !== '')
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
@ -75,14 +74,14 @@
<div class="mt-6 flex items-center justify-end gap-2">
@if ($done)
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
@else
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
@if (! $error && $supported)
<x-btn variant="accent" wire:click="upgrade" wire:target="upgrade" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="upgrade" />
<svg wire:loading wire:target="upgrade" 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>
Jetzt aktualisieren
{{ __('modals.system_update.submit') }}
</x-btn>
@endif
@endif

View File

@ -484,7 +484,7 @@
</x-panel>
</div>
{{-- SSH-Schlüssel --}}
{{-- SSH keys --}}
<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 }} } })">

View File

@ -1,13 +1,20 @@
@php
// Map a Keep-a-Changelog subsection to a badge tone.
$tone = fn (string $g): string => [
'Hinzugefügt' => 'accent',
'Geändert' => 'cyan',
'Behoben' => 'neutral',
'Sicherheit' => 'accent',
'Entfernt' => 'neutral',
'Veraltet' => 'neutral',
][$g] ?? 'neutral';
// CHANGELOG.md uses German Keep-a-Changelog headings as the canonical section keys;
// map each to a localized display label + a badge tone.
$sectionKey = [
'Hinzugefügt' => 'added',
'Geändert' => 'changed',
'Behoben' => 'fixed',
'Sicherheit' => 'security',
'Entfernt' => 'removed',
'Veraltet' => 'deprecated',
];
$sectionTone = [
'added' => 'accent', 'changed' => 'cyan', 'fixed' => 'neutral',
'security' => 'accent', 'removed' => 'neutral', 'deprecated' => 'neutral',
];
$sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g;
$tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral';
$repoHost = parse_url($repository, PHP_URL_HOST);
$repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/');
@ -92,7 +99,7 @@
@foreach ($rel['groups'] as $group => $items)
<div>
<div class="mb-1.5 flex items-center gap-2">
<x-badge :tone="$tone($group)" class="shrink-0">{{ $group }}</x-badge>
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge>
</div>
<ul class="space-y-1.5 pl-0.5">
@foreach ($items as $text)