diff --git a/docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md b/docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md new file mode 100644 index 0000000..80ff5df --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-server-details-hardening-polish.md @@ -0,0 +1,1073 @@ +# 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=` +- 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 +x'); + $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'); + $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) + merge(['class' => $classes]) }}>{{ $slot }} +@else + +@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 ` + @endunless + + @endforeach + +
+ + {{ __('common.add') }} +
+ + @endif + + @endif + + @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) +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 +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 +`

{{ $supported ? $check['detail'] : $check['reason'] }}

` line from Task 3), add: +```blade + @if ($supported && $check['key'] === 'ssh_password' && $check['featureOn']) +

{{ __('servers.ssh_key_only_hint') }}

+ @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) +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.