# Server-Details UX + Hardening Polish — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix seven reported Server-Details / button / create-flow issues — uniform bordered buttons, an installed-only firewall+fail2ban grid, auto-updates reframed as a neutral preference, SSH-verified server creation with an "Initialisierung" status, and an SSH key-only checklist hint.

**Architecture:** Mostly Blade/Livewire on `servers.show` plus a small `HardeningService` semantics change, a new `FleetService::testConnection`, and a `CreateServer` guard. Buttons stay centralized in the `x-btn` component. Status `pending` is a new string value — no migration (the `status` column is free-form).

**Tech Stack:** Laravel 13, Livewire 3 (class-based), Tailwind v4 (`@theme` tokens), Pest/PHPUnit, phpseclib. All tooling runs in the `app` container (R8). UI copy bilingual DE+EN (R16).

**Conventions reused:**
- Run tests: `docker compose exec -T app php artisan test --filter=<Name>`
- Pint: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty`
- Build: `docker compose exec -T -u "1002:1002" app npm run build`
- Commit messages: Conventional Commits, end with the Co-Authored-By trailer.

---

### Task 1: Unified button kit (`x-btn`)

Persistent border+bg on every variant; retire `ghost`/`ghost-danger`; add `danger-soft`.

**Files:**
- Modify: `resources/views/components/btn.blade.php`
- Test: `tests/Feature/ButtonComponentTest.php` (create)

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Blade;
use Tests\TestCase;

class ButtonComponentTest extends TestCase
{
    public function test_danger_soft_has_persistent_border_and_bg(): void
    {
        $html = Blade::render('<x-btn variant="danger-soft">x</x-btn>');
        $this->assertStringContainsString('border border-offline/25', $html);
        $this->assertStringContainsString('bg-offline/10', $html);
        $this->assertStringContainsString('text-offline', $html);
    }

    public function test_no_borderless_ghost_variants_remain(): void
    {
        // Unknown variants fall back to secondary (bordered) — never borderless.
        $html = Blade::render('<x-btn variant="ghost">x</x-btn>');
        $this->assertStringContainsString('border border-line', $html);
        $this->assertStringContainsString('bg-inset', $html);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=ButtonComponentTest`
Expected: FAIL (`danger-soft` not in map → falls back to secondary, so `bg-offline/10` missing).

- [ ] **Step 3: Replace the variant map**

Full new `resources/views/components/btn.blade.php`:

```php
@props(['variant' => 'secondary', 'icon' => false, 'href' => null, 'size' => 'sm'])
@php
    // One button style for the whole app — EVERY variant carries a persistent
    // border + background (R10). No borderless variants: low-emphasis actions use
    // `secondary`, destructive ones use `danger-soft` (red-tinted, always — not
    // only on hover). Unknown variants fall back to the bordered `secondary`.
    $variants = [
        'primary' => 'bg-accent text-void hover:bg-accent-bright',
        'accent' => 'border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15',
        'secondary' => 'border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink',
        'danger' => 'bg-offline text-void hover:bg-offline/90',
        'danger-soft' => 'border border-offline/25 bg-offline/10 text-offline hover:bg-offline/15',
    ];
    // sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7).
    $sizes = [
        'sm' => ($icon ? 'h-8 w-8' : 'h-8 px-3').' text-xs',
        'lg' => ($icon ? 'h-11 w-11' : 'h-11 px-4').' text-sm',
    ];
    $classes = 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors '
        .'disabled:opacity-50 disabled:pointer-events-none '
        .($sizes[$size] ?? $sizes['sm']).' '.($variants[$variant] ?? $variants['secondary']);
@endphp
@if ($href)
    <a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
@else
    <button {{ $attributes->merge(['type' => 'button', 'class' => $classes]) }}>{{ $slot }}</button>
@endif
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=ButtonComponentTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Migrate all `ghost` / `ghost-danger` usages**

Map: `ghost` → `secondary`, `ghost-danger` → `danger-soft`. Edit each:
- `resources/views/livewire/servers/show.blade.php`: line 128 `ghost-danger`→`danger-soft`; 210 `ghost`→`secondary`; 323 `ghost-danger`→`danger-soft`; 354 `ghost`→`secondary`; 398 `ghost`→`secondary`; 507 `ghost-danger`→`danger-soft`.
- `resources/views/livewire/settings/index.blade.php`: line 109 `ghost-danger`→`danger-soft`.
- `resources/views/livewire/files/index.blade.php`: lines 122,123 `ghost`→`secondary`; 125 `ghost-danger`→`danger-soft`.

Leave the two raw chip-x `<button>`s (`show.blade.php:417`, `add-ssh-key.blade.php:15`) — they are inline chip/link affordances, not buttons.

Verify none remain:
Run: `grep -rn 'variant="ghost' resources/views/`
Expected: no output.

- [ ] **Step 6: Build + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
docker compose exec -T -u "1002:1002" app npm run build
git add resources/views/components/btn.blade.php resources/views/livewire/servers/show.blade.php resources/views/livewire/settings/index.blade.php resources/views/livewire/files/index.blade.php tests/Feature/ButtonComponentTest.php
git commit -m "$(cat <<'EOF'
feat(ui): uniform bordered button kit; retire ghost variants

Every x-btn variant now carries a persistent border + background. Replace the
borderless `ghost`/`ghost-danger` (the latter only reddened on hover) with the
bordered `secondary` and a new `danger-soft` (red-tinted border+bg, always).
Migrate all 10 usages across servers/settings/files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 2: `pending` status ("Initialisierung") in status maps + lang

**Files:**
- Modify: `resources/views/components/status-pill.blade.php`, `status-dot.blade.php`, `server-item.blade.php`, `resources/views/livewire/servers/index.blade.php`
- Modify: `lang/de/servers.php`, `lang/en/servers.php`
- Test: `tests/Feature/StatusComponentTest.php` (create)

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Blade;
use Tests\TestCase;

class StatusComponentTest extends TestCase
{
    public function test_pending_pill_uses_cyan_tone(): void
    {
        $html = Blade::render('<x-status-pill status="pending">Init</x-status-pill>');
        $this->assertStringContainsString('text-cyan', $html);
        $this->assertStringContainsString('bg-cyan/10', $html);
    }

    public function test_pending_dot_uses_cyan(): void
    {
        $html = Blade::render('<x-status-dot status="pending" :ping="true" />');
        $this->assertStringContainsString('bg-cyan', $html);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=StatusComponentTest`
Expected: FAIL (pending falls back to gray `bg-line` / `bg-ink-4`).

- [ ] **Step 3: Add `pending` to the maps**

`resources/views/components/status-pill.blade.php` — extend `$c` and the ping:
```php
@props(['status' => 'offline'])
@php
    $c = [
        'online'  => 'text-online border-online/20 bg-online/10',
        'warning' => 'text-warning border-warning/20 bg-warning/10',
        'offline' => 'text-offline border-offline/20 bg-offline/10',
        'pending' => 'text-cyan border-cyan/20 bg-cyan/10',
    ][$status] ?? 'text-ink-3 border-line bg-line';
@endphp
<span {{ $attributes->merge(['class' => "inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-xs $c"]) }}>
    <x-status-dot :status="$status" :ping="in_array($status, ['online', 'pending'], true)" />
    {{ $slot }}
</span>
```

`resources/views/components/status-dot.blade.php` — extend `$c` and `$glow`:
```php
@props(['status' => 'offline', 'ping' => false])
@php
    $c = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline', 'pending' => 'bg-cyan'][$status] ?? 'bg-ink-4';
    $glow = ['online' => 'shadow-[0_0_6px_var(--color-online)]', 'warning' => 'shadow-[0_0_5px_var(--color-warning)]', 'pending' => 'shadow-[0_0_5px_var(--color-cyan)]'][$status] ?? '';
@endphp
<span {{ $attributes->merge(['class' => 'relative inline-flex h-2 w-2 shrink-0']) }}>
    @if ($ping)
        <span class="absolute inline-flex h-full w-full rounded-full {{ $c }} opacity-60 motion-safe:animate-ping"></span>
    @endif
    <span class="relative inline-flex h-2 w-2 rounded-full {{ $c }} {{ $glow }}"></span>
</span>
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=StatusComponentTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Add the label + wire it into the list views**

Append to `lang/de/servers.php` (near `status_offline`):
```php
    'status_pending' => 'Initialisierung',
```
Append to `lang/en/servers.php`:
```php
    'status_pending' => 'Initializing',
```

`resources/views/livewire/servers/index.blade.php` line 2 — add pending to `$label`:
```php
    $label = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline'), 'pending' => __('servers.status_pending')];
```
Same file line 59 — ping on pending too:
```php
                        <x-status-dot :status="$server->status" :ping="in_array($server->status, ['online', 'pending'], true)" />
```

`resources/views/components/server-item.blade.php` line 3 — add pending to the label map; line 6 — ping on pending:
```php
@props(['name', 'ip', 'status' => 'online', 'cpu' => 0, 'mem' => 0, 'os' => null])
@php
    $label = ['online' => __('common.online'), 'warning' => __('common.warning'), 'offline' => __('common.offline'), 'pending' => __('servers.status_pending')][$status] ?? ucfirst($status);
@endphp
```
(line 6 dot:) `<x-status-dot :status="$status" :ping="in_array($status, ['online', 'pending'], true)" />`

- [ ] **Step 6: Pint + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add resources/views/components/status-pill.blade.php resources/views/components/status-dot.blade.php resources/views/components/server-item.blade.php resources/views/livewire/servers/index.blade.php lang/de/servers.php lang/en/servers.php tests/Feature/StatusComponentTest.php
git commit -m "$(cat <<'EOF'
feat(ui): add "Initialisierung" (pending) server status

New `pending` status renders cyan (dot + pill, soft ping) instead of red, for
freshly created servers that have not been contacted yet. Wired into the
status-pill/dot components, the fleet list, and server-item, with a bilingual
`servers.status_pending` label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 3: Auto-updates → neutral preference (`HardeningService` + checklist Blade)

**Files:**
- Modify: `app/Services/HardeningService.php` (`parseState`, ~lines 122-144)
- Modify: `resources/views/livewire/servers/show.blade.php` (Sicherheit checklist, ~lines 180-222)
- Test: `tests/Feature/HardeningServiceTest.php` (extend existing)

- [ ] **Step 1: Write the failing test (add to `HardeningServiceTest`)**

```php
    public function test_autoupdate_row_is_neutral_and_never_insecure(): void
    {
        $os = $this->debianProfile();

        $detector = \Mockery::mock(\App\Support\Os\OsDetector::class);
        $detector->shouldReceive('detect')->andReturn($os);

        $fleet = \Mockery::mock(\App\Services\FleetService::class);
        $fleet->shouldReceive('runPrivileged')->andReturn([
            'ok' => true,
            // auto-update reported INACTIVE by the host:
            'output' => "fail2ban 1 active\nfirewall 1 active\nautoupdate 1 inactive",
        ]);

        $service = new \App\Services\HardeningService($fleet, \Mockery::mock(\App\Services\FirewallService::class), $detector);
        $rows = $service->state(new \App\Models\Server());

        $au = collect($rows)->firstWhere('key', 'unattended');
        $this->assertTrue($au['secure'], 'auto-updates must never read as insecure');
        $this->assertTrue($au['neutral'] ?? false, 'auto-updates row must be flagged neutral');
        $this->assertFalse($au['featureOn'], 'inactive auto-updates → toggle shows enable');
    }
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=HardeningServiceTest`
Expected: FAIL (`secure` is false for inactive autoupdate; no `neutral` key).

- [ ] **Step 3: Add the `neutral` flag + force `secure` on the autoupdate row**

In `app/Services/HardeningService.php::parseState`, change the `$row` closure to accept a `neutral` flag, and update the docblock return type to include `neutral:bool`:
```php
        $row = fn (string $key, string $label, string $detail, bool $featureOn, bool $secure, ?string $reason, bool $neutral = false): array => [
            'key' => $key, 'label' => $label, 'detail' => $detail,
            'featureOn' => $featureOn, 'secure' => $secure,
            'supported' => $reason === null, 'reason' => $reason, 'neutral' => $neutral,
        ];
```
Then change ONLY the `unattended` row (the last entry in the returned array) to force `secure = true` and `neutral = true` (auto-updates are an operator preference, never a security verdict):
```php
            $row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), true, $os->supports('auto_updates'), true),
```
(The other four rows keep the default `neutral = false`.)

Also update the method docblock (~line 97) array shape to add `, neutral:bool`.

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=HardeningServiceTest`
Expected: PASS (both tests in the file).

- [ ] **Step 5: Render the neutral row without SICHER/OFFEN (Blade)**

In `resources/views/livewire/servers/show.blade.php`, the checklist `@foreach ($hardening as $check)` block. Replace the `@php $supported = ...` line and the icon/state-chip markup (current lines ~181-205) with:

```blade
                        @php
                            $supported = $check['supported'] ?? true;
                            $neutral = $check['neutral'] ?? false;
                        @endphp
                        <div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
                            <span @class([
                                'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
                                'border-online/25 bg-online/10 text-online' => $supported && ! $neutral && $check['secure'],
                                'border-warning/25 bg-warning/10 text-warning' => $supported && ! $neutral && ! $check['secure'],
                                'border-line bg-raised text-ink-4' => ! $supported || $neutral,
                            ])>
                                <x-icon :name="$supported && ! $neutral && ! $check['secure'] ? 'lock-open' : 'lock'" class="h-4 w-4" />
                            </span>
                            <div class="min-w-0 flex-1">
                                <p class="flex items-center gap-2 truncate text-sm text-ink">
                                    {{ $check['label'] }}
                                    @if (! $supported)
                                        <span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.unsupported') }}</span>
                                    @elseif ($neutral)
                                        <span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ $check['featureOn'] ? __('common.active') : __('common.inactive') }}</span>
                                    @else
                                        <span @class([
                                            'shrink-0 font-mono text-[10px] uppercase tracking-wider',
                                            'text-online' => $check['secure'],
                                            'text-warning' => ! $check['secure'],
                                        ])>{{ $check['secure'] ? __('servers.check_secure') : __('servers.check_open') }}</span>
                                    @endif
                                </p>
                                <p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>
                            </div>
```

(The `@if ($supported)` action block that follows — the config gear + Aktivieren/Deaktivieren button — is unchanged. The SSH hint added in Task 7 goes just after the detail `<p>`.)

- [ ] **Step 6: Pint + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Services/HardeningService.php resources/views/livewire/servers/show.blade.php tests/Feature/HardeningServiceTest.php
git commit -m "$(cat <<'EOF'
feat(hardening): treat auto-updates as a neutral operator preference

Automatic updates are a choice, not a security gate: the unattended row now
reports secure=true always and carries a `neutral` flag. The checklist renders
it muted (aktiv/inaktiv) instead of the green/orange SICHER/OFFEN verdict, so
"off" is never flagged as insecure. Detection is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 4: `FleetService::testConnection`

**Files:**
- Modify: `app/Services/FleetService.php`
- Test: `tests/Feature/FleetTestConnectionTest.php` (create)

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature;

use App\Models\Server;
use App\Models\SshCredential;
use App\Services\FleetService;
use Tests\TestCase;

class FleetTestConnectionTest extends TestCase
{
    use \Illuminate\Foundation\Testing\RefreshDatabase;

    public function test_unreachable_host_returns_error_without_throwing(): void
    {
        $server = Server::create(['name' => 'x', 'ip' => '127.0.0.1', 'ssh_port' => 1, 'status' => 'pending']);
        $server->credential()->create([
            'username' => 'root', 'auth_type' => 'password', 'secret' => 'nope',
        ]);
        $server->load('credential');

        $res = app(FleetService::class)->testConnection($server);

        $this->assertFalse($res['ok']);
        $this->assertNotNull($res['error']);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=FleetTestConnectionTest`
Expected: FAIL (`Call to undefined method ... testConnection`).

- [ ] **Step 3: Add `testConnection` to `FleetService`**

Add this public method (place it near `runPlain`, after the constructor/`client` helpers). It builds its own short-timeout `SshClient` so a probe never blocks for the full default timeout:
```php
    /**
     * Probe the server's stored credential: connect + a trivial exec. NEVER throws —
     * returns the failure reason instead, for the create-server guard to surface.
     *
     * @return array{ok: bool, error: ?string}
     */
    public function testConnection(Server $server): array
    {
        try {
            $ssh = (new \App\Support\Ssh\SshClient($this->vault, timeout: 10))->connect($server);
            try {
                [, $code] = $ssh->run('true');
            } finally {
                $ssh->disconnect();
            }

            return ['ok' => $code === 0, 'error' => null];
        } catch (\Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }
    }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=FleetTestConnectionTest`
Expected: PASS (connection refused on 127.0.0.1:1 → ok=false, error set).

- [ ] **Step 5: Pint + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Services/FleetService.php tests/Feature/FleetTestConnectionTest.php
git commit -m "$(cat <<'EOF'
feat(ssh): add FleetService::testConnection credential probe

Connect + trivial exec against a server's stored credential; never throws,
returns {ok,error}. Used by the create-server guard to verify the SSH login
before persisting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 5: Create-server verifies SSH + sets `pending`

**Files:**
- Modify: `app/Livewire/Modals/CreateServer.php`
- Modify: `lang/de/modals.php`, `lang/en/modals.php`
- Test: `tests/Feature/CreateServerTest.php` (create)

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature;

use App\Livewire\Modals\CreateServer;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use Livewire\Livewire;
use Tests\TestCase;

class CreateServerTest extends TestCase
{
    use \Illuminate\Foundation\Testing\RefreshDatabase;

    private function form(\Livewire\Features\SupportTesting\Testable $c): \Livewire\Features\SupportTesting\Testable
    {
        return $c->set('name', 'web-1')->set('ip', '10.0.0.5')->set('sshPort', 22)
            ->set('username', 'root')->set('authType', 'password')->set('secret', 'pw');
    }

    public function test_bad_ssh_blocks_creation_and_rolls_back(): void
    {
        $this->actingAs(User::factory()->create());
        $this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => false, 'error' => 'auth failed']));

        $this->form(Livewire::test(CreateServer::class))->call('save')->assertHasErrors('secret');

        $this->assertSame(0, Server::count());
    }

    public function test_good_ssh_creates_server_as_pending(): void
    {
        $this->actingAs(User::factory()->create());
        $this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => true, 'error' => null]));

        $this->form(Livewire::test(CreateServer::class))->call('save')->assertHasNoErrors();

        $this->assertSame(1, Server::count());
        $this->assertSame('pending', Server::first()->status);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=CreateServerTest`
Expected: FAIL (no SSH check; server saved as `offline`; bad-SSH test creates a row).

- [ ] **Step 3: Add the guard + `pending` status to `CreateServer::save()`**

In `app/Livewire/Modals/CreateServer.php`, add imports at the top:
```php
use App\Services\FleetService;
use Illuminate\Validation\ValidationException;
```
Replace the `save()` body's transaction so it sets `status => 'pending'` and probes SSH before committing (throwing rolls the transaction back):
```php
    public function save(): void
    {
        $data = $this->validate();

        // Atomic: create server + credential, then VERIFY the SSH login. A failed
        // probe throws -> the transaction rolls back, so no half-registered server
        // is left behind, and the operator sees the real SSH reason on the form.
        $server = DB::transaction(function () use ($data) {
            $server = Server::create([
                'name' => trim($data['name']),
                'ip' => trim($data['ip']),
                'ssh_port' => (int) $data['sshPort'],
                'status' => 'pending',
            ]);

            $server->credential()->create([
                'name' => trim($this->credentialName) !== '' ? trim($this->credentialName) : null,
                'username' => trim($data['username']),
                'auth_type' => $data['authType'] === 'key' ? 'key' : 'password',
                'secret' => $this->secret,
                'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null,
            ]);

            $server->load('credential');
            $probe = app(FleetService::class)->testConnection($server);
            if (! $probe['ok']) {
                throw ValidationException::withMessages([
                    'secret' => __('modals.create_server.validation_ssh_failed', ['error' => \Illuminate\Support\Str::limit((string) $probe['error'], 120)]),
                ]);
            }

            AuditEvent::create([
                'user_id' => Auth::id(),
                'server_id' => $server->id,
                'actor' => Auth::user()?->name ?? 'system',
                'action' => 'server.create',
                'target' => $server->name.' · '.$server->ip,
                'ip' => request()->ip(),
            ]);

            return $server;
        });

        $this->dispatch('serverCreated');
        $this->dispatch('notify', message: __('modals.create_server.notify_added'));
        $this->closeModal();
    }
```

- [ ] **Step 4: Add the lang key**

Append to `lang/de/modals.php` inside the `create_server` array:
```php
        'validation_ssh_failed' => 'SSH-Verbindung fehlgeschlagen: :error. Bitte Zugangsdaten prüfen.',
```
Append to `lang/en/modals.php` inside the `create_server` array:
```php
        'validation_ssh_failed' => 'SSH connection failed: :error. Please check the credentials.',
```

- [ ] **Step 5: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=CreateServerTest`
Expected: PASS (2 tests).

- [ ] **Step 6: Pint + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add app/Livewire/Modals/CreateServer.php lang/de/modals.php lang/en/modals.php tests/Feature/CreateServerTest.php
git commit -m "$(cat <<'EOF'
feat(servers): verify SSH on create + start in "Initialisierung"

CreateServer now probes the entered SSH login inside the create transaction; a
failed probe throws and rolls back (no half-registered server), surfacing the
SSH reason on the form. Successful creation starts the server as `pending`
("Initialisierung") instead of red/offline until first contact promotes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 6: Firewall + fail2ban panels → installed-only responsive grid

**Files:**
- Modify: `resources/views/livewire/servers/show.blade.php` (lines 227-431, the two panels)
- Test: `tests/Feature/ServerShowPanelsTest.php` (create)

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature;

use App\Livewire\Servers\Show;
use App\Models\Server;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;

class ServerShowPanelsTest extends TestCase
{
    use \Illuminate\Foundation\Testing\RefreshDatabase;

    private function server(): Server
    {
        return Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
    }

    public function test_firewall_panel_hidden_when_not_installed(): void
    {
        $this->actingAs(User::factory()->create());

        Livewire::test(Show::class, ['server' => $this->server()])
            ->set('ready', true)
            ->set('firewall', ['supported' => true, 'installed' => false])
            ->set('fail2ban', ['supported' => true, 'installed' => false])
            ->assertDontSee(__('servers.firewall_title'))
            ->assertDontSee(__('servers.fail2ban_title'));
    }

    public function test_both_panels_shown_when_installed(): void
    {
        $this->actingAs(User::factory()->create());

        Livewire::test(Show::class, ['server' => $this->server()])
            ->set('ready', true)
            ->set('firewall', ['supported' => true, 'installed' => true, 'active' => true, 'tool' => 'ufw', 'manageable' => true, 'rules' => [], 'defaults' => []])
            ->set('fail2ban', ['supported' => true, 'installed' => true, 'active' => true, 'jails' => [], 'ignoreip' => []])
            ->assertSee(__('servers.firewall_title'))
            ->assertSee(__('servers.fail2ban_title'));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=ServerShowPanelsTest`
Expected: FAIL — `test_firewall_panel_hidden_when_not_installed` sees the panel title (current Blade renders a "not installed" box).

- [ ] **Step 3: Replace the two-panel section with a gated grid**

In `resources/views/livewire/servers/show.blade.php`, replace EVERYTHING from the `{{-- Firewall-Regeln --}}` comment (line ~227) through the end of the fail2ban `</x-panel>` (line ~431) with:

````blade
        {{-- Firewall + fail2ban: only the tools actually installed, side-by-side on lg --}}
        @php
            $fw = $firewall ?? [];
            $fwTool = $fw['tool'] ?? 'ufw';
            $fwToolLabel = $fwTool === 'firewalld' ? 'firewalld' : 'UFW';
            $fwSupported = $fw['supported'] ?? false;
            $fwInstalled = $fw['installed'] ?? false;
            $fwActive = $fw['active'] ?? false;
            $fwManageable = $fw['manageable'] ?? false;
            $fwReadOnly = $fw['readOnly'] ?? false;
            $fwReadError = $fw['readError'] ?? false;
            $fwRules = $fw['rules'] ?? [];
            $fwDefaults = $fw['defaults'] ?? [];
            $actTone = ['ALLOW' => 'online', 'LIMIT' => 'warning', 'DENY' => 'offline', 'REJECT' => 'offline'];

            $f2b = $fail2ban ?? [];
            $f2bSupported = $f2b['supported'] ?? false;
            $f2bReadError = $f2b['readError'] ?? false;
            $f2bInstalled = $f2b['installed'] ?? false;
            $f2bActive = $f2b['active'] ?? false;
            $f2bJails = $f2b['jails'] ?? [];
            $f2bIgnore = $f2b['ignoreip'] ?? [];
            $f2bBanned = array_sum(array_map(fn ($j) => $j['currentlyBanned'] ?? 0, $f2bJails));

            // Show a panel ONLY when its tool is installed (active or inactive). When
            // absent, the Sicherheit checklist row carries the "Aktivieren" install path.
            $showFw = $fwSupported && $fwInstalled;
            $showF2b = $f2bSupported && $f2bInstalled;
            $panelCount = ($showFw ? 1 : 0) + ($showF2b ? 1 : 0);
        @endphp

        @if ($panelCount > 0)
        <div @class(['grid grid-cols-1 gap-3', 'lg:grid-cols-2 lg:items-start' => $panelCount === 2])>
            @if ($showFw)
                <x-panel :title="__('servers.firewall_title')"
                         :subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))"
                         :padded="false">
                    @if ($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" /> {{ __('servers.firewall_add_rule') }}
                            </x-btn>
                        </x-slot:actions>
                    @endif

                    @if ($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">{{ __('servers.firewall_read_error') }}</p>
                        </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">{{ __('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 } })">
                                {{ __('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">{{ __('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">{{ __('servers.firewall_zone') }}</span>
                                <x-badge tone="neutral">{{ $fwDefaults['zone'] ?? '—' }}</x-badge>
                            @endif
                        </div>

                        @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">{{ __('servers.firewall_readonly') }}</p>
                            </div>
                        @endif

                        <div class="divide-y divide-line">
                            @forelse ($fwRules as $rule)
                                @php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp
                                <div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
                                    <span @class([
                                        'h-2 w-2 shrink-0 rounded-full',
                                        'bg-online' => $ruleTone === 'online',
                                        'bg-warning' => $ruleTone === 'warning',
                                        'bg-offline' => $ruleTone === 'offline',
                                    ])></span>
                                    <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">{{ __('servers.firewall_rule_from', ['value' => $rule['from']]) }}</p>
                                        @endif
                                    </div>
                                    <span @class([
                                        'shrink-0 font-mono text-[10px] uppercase tracking-wider',
                                        'text-online' => $ruleTone === 'online',
                                        'text-warning' => $ruleTone === 'warning',
                                        'text-offline' => $ruleTone === 'offline',
                                    ])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
                                    @unless ($fwReadOnly)
                                        <x-btn variant="danger-soft" 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>
                                    @endunless
                                </div>
                            @empty
                                <div class="px-4 py-4 sm:px-5">
                                    <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_no_rules') }}</p>
                                </div>
                            @endforelse
                        </div>
                    @endif
                </x-panel>
            @endif

            @if ($showF2b)
                <x-panel :title="__('servers.fail2ban_title')"
                         :subtitle="$f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')"
                         :padded="false">
                    @if ($f2bActive)
                        <x-slot:actions>
                            <x-btn variant="secondary" 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" /> {{ __('servers.fail2ban_ban_ip') }}
                            </x-btn>
                        </x-slot:actions>
                    @endif

                    @if ($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">{{ __('servers.fail2ban_read_error') }}</p>
                        </div>
                    @elseif (! $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">{{ __('servers.fail2ban_state_installed_inactive') }}</p>
                            <x-btn variant="secondary" size="sm"
                                   wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'fail2ban', enable: true } })">
                                {{ __('common.enable') }}
                            </x-btn>
                        </div>
                    @else
                        <div class="divide-y divide-line">
                            @foreach ($f2bJails as $jail)
                                <div class="px-4 py-3 sm:px-5">
                                    <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])>{{ __('servers.fail2ban_banned', ['count' => $jail['currentlyBanned'] ?? 0]) }}</span>
                                            · {{ __('servers.fail2ban_failed', ['count' => $jail['currentlyFailed'] ?? 0]) }}
                                        </p>
                                    </div>
                                    @if (count($jail['bannedIps'] ?? []))
                                        <div class="mt-2 space-y-1">
                                            @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="secondary" 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">{{ __('servers.fail2ban_no_banned') }}</p>
                                    @endif
                                </div>
                            @endforeach
                        </div>

                        <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">{{ __('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="{{ __('common.remove') }}">
                                                <x-icon name="x" class="h-3 w-3" />
                                            </button>
                                        @endunless
                                    </span>
                                @endforeach
                            </div>
                            <div class="mt-2.5 flex items-center gap-2">
                                <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">{{ __('common.add') }}</x-btn>
                            </div>
                        </div>
                    @endif
                </x-panel>
            @endif
        </div>
        @endif
````

Notes: trash rule button is now `danger-soft` (Task 1 migration applied here too); unban button is `secondary`; the firewall config-gear that lived in the *checklist* (Task 1, line 210→`secondary`) is unaffected by this block.

- [ ] **Step 4: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=ServerShowPanelsTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Build + Pint + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
docker compose exec -T -u "1002:1002" app npm run build
git add resources/views/livewire/servers/show.blade.php tests/Feature/ServerShowPanelsTest.php
git commit -m "$(cat <<'EOF'
feat(servers): show firewall/fail2ban only when installed, in a grid

Each panel renders only when its tool is actually installed (the Sicherheit
checklist carries the install/Aktivieren path otherwise), removing the redundant
"nicht installiert" boxes. Both shown -> 2-col responsive grid (lg, items-start);
one shown -> full width. Trash uses danger-soft, unban uses secondary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 7: SSH key-only hint on the checklist

**Files:**
- Modify: `resources/views/livewire/servers/show.blade.php` (checklist detail area)
- Modify: `lang/de/servers.php`, `lang/en/servers.php`
- Test: `tests/Feature/ServerShowSshHintTest.php` (create)

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature;

use App\Livewire\Servers\Show;
use App\Models\Server;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;

class ServerShowSshHintTest extends TestCase
{
    use \Illuminate\Foundation\Testing\RefreshDatabase;

    private function show(array $hardening): \Livewire\Features\SupportTesting\Testable
    {
        $this->actingAs(User::factory()->create());
        $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);

        return Livewire::test(Show::class, ['server' => $server])->set('ready', true)->set('hardening', $hardening);
    }

    public function test_key_only_hint_shown_when_password_login_on(): void
    {
        $this->show([
            ['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication yes', 'featureOn' => true, 'secure' => false, 'supported' => true, 'reason' => null],
        ])->assertSee(__('servers.ssh_key_only_hint'));
    }

    public function test_key_only_hint_hidden_when_password_login_off(): void
    {
        $this->show([
            ['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication no', 'featureOn' => false, 'secure' => true, 'supported' => true, 'reason' => null],
        ])->assertDontSee(__('servers.ssh_key_only_hint'));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker compose exec -T app php artisan test --filter=ServerShowSshHintTest`
Expected: FAIL (hint key not rendered).

- [ ] **Step 3: Add the hint after the detail line**

In `resources/views/livewire/servers/show.blade.php`, in the checklist row (just after the
`<p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>` line from Task 3), add:
```blade
                                @if ($supported && $check['key'] === 'ssh_password' && $check['featureOn'])
                                    <p class="mt-0.5 font-mono text-[11px] text-ink-4">{{ __('servers.ssh_key_only_hint') }}</p>
                                @endif
```
(Scope: only `ssh_password` — disabling it is what forces key-only access; `ssh_root` disable does not, so it carries no such hint.)

- [ ] **Step 4: Add the lang key**

Append to `lang/de/servers.php`:
```php
    'ssh_key_only_hint' => 'Nach dem Deaktivieren ist der Zugang nur per SSH-Key möglich — Key zuerst hinterlegen.',
```
Append to `lang/en/servers.php`:
```php
    'ssh_key_only_hint' => 'After disabling, access is SSH-key only — deposit a key first.',
```

- [ ] **Step 5: Run test to verify it passes**

Run: `docker compose exec -T app php artisan test --filter=ServerShowSshHintTest`
Expected: PASS (2 tests).

- [ ] **Step 6: Pint + commit**

```bash
docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty
git add resources/views/livewire/servers/show.blade.php lang/de/servers.php lang/en/servers.php tests/Feature/ServerShowSshHintTest.php
git commit -m "$(cat <<'EOF'
feat(servers): hint that disabling password login leaves key-only access

Adds a muted one-line hint on the SSH-Passwort-Login checklist row while
password auth is still on, so the operator knows access becomes SSH-key only
(and to deposit a key first) before toggling — mirroring the lockout guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 8: Full verification (R12 + Codex)

No code changes — the acceptance gate.

- [ ] **Step 1: Full suite + Pint clean**

Run: `docker compose exec -T app php artisan test`
Run: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --test`
Expected: all green, no style issues.

- [ ] **Step 2: Build**

Run: `docker compose exec -T -u "1002:1002" app npm run build`
Expected: Vite build OK.

- [ ] **Step 3: R12 browser — DE + EN, 375 / 768 / 1280**

Per `docs/session-handoff.md` §5 recipe (Puppeteer image, temp `/__peek` route, remove before commit). Server-Details page (`/`→servers→show) checks:
- buttons uniform (border+bg everywhere; Löschen/trash red-tinted persistent, not hover-only);
- firewall + fail2ban panels appear only when installed, in a 2-col grid (lg), full width when one;
- auto-update row reads neutral (aktiv/inaktiv, no orange OFFEN);
- SSH-Passwort row shows the key-only hint while on;
- create-server modal blocks on a bad password (error shown), no server row created;
- a freshly created (good-cred) server shows "Initialisierung" (cyan) in the fleet list.
- HTTP 200, 0 console errors, DOM scan for leaked `@`/`{{ }}`/`$var`/`group.key` (R17).

- [ ] **Step 4: Codex review (R15)**

Run: `export PATH="$HOME/.npm-global/bin:$PATH"; codex review --uncommitted` (run before the final commit if anything is left unstaged; otherwise review the branch range). Fix + re-run until clean.

- [ ] **Step 5: Update handoff doc**

Note in `docs/session-handoff.md` that these 7 items are done on `feat/v1-foundation` (not yet tagged/pushed). Commit as `docs:`.

---

## Self-Review

**Spec coverage:**
- Issue 1 (UFW box hidden when not installed) → Task 6. ✓
- Issue 2 (auto-updates default neutral) → Task 3. ✓
- Issue 3 (SSH key-only hint) → Task 7. ✓
- Issue 4 (create-verify + Initialisierung) → Tasks 4, 5 (+ status maps in Task 2). ✓
- Issue 5 (grid sizing) → Task 6. ✓
- Issue 6 (consistent buttons / one definition) → Task 1. ✓
- Issue 7 (delete button bordered danger, not hover-red) → Task 1 (`danger-soft`) + applied in Task 6. ✓

**Type/name consistency:** `danger-soft` used identically in Tasks 1 + 6. `pending` used in Tasks 2 + 5. `neutral` row flag defined in Task 3 (`HardeningService`) and consumed in Task 3 Blade. `testConnection` defined in Task 4, called in Task 5. `servers.status_pending` added in Task 2, reused in Task 2 views. `servers.ssh_key_only_hint` added in Task 7. `modals.create_server.validation_ssh_failed` added in Task 5. No dangling references.

**Placeholder scan:** none — every code step shows complete content.

**Ordering note:** Task 1 migrates the trash/unban buttons in `show.blade.php`; Task 6 re-pastes that section with the same `danger-soft`/`secondary` choices, so the two are consistent even though Task 6 overwrites the region.
