feat(ui): shield-only 2FA indicator in the sidebar + terminal search from 2 servers

- sidebar: the "2FA on/off" text badge next to the user name was visually noisy. Replace it
  with a single status shield — a cyan shield-check when 2FA is on, a muted struck-through
  shield when off; the label moves to the hover/aria title so it stays reachable.
- terminal: the server-target search now appears once there is more than one server (was 5+),
  so it is there as soon as filtering helps; added a clear (×) button + an accent focus ring.

Browser-verified: sidebar shows just the shield (no text); search filters the rail (homelab →
only homelab-01) with a working clear button; both badge states render correctly. 465 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 20:20:46 +02:00
parent 2b1e98c5d4
commit 00a9d8b9a3
8 changed files with 30 additions and 18 deletions

View File

@ -20,8 +20,8 @@ use Livewire\Component;
#[Layout('layouts.app')] #[Layout('layouts.app')]
class Index extends Component class Index extends Component
{ {
/** Show the server search box once the fleet is large enough to warrant filtering. */ /** Show the server search box once there is more than one server to filter between. */
private const SEARCH_THRESHOLD = 5; private const SEARCH_THRESHOLD = 1;
/** 'host' or a server uuid — drives the active-target highlight in the rail. */ /** 'host' or a server uuid — drives the active-target highlight in the rail. */
public string $activeKey = ''; public string $activeKey = '';

View File

@ -13,6 +13,7 @@ return [
'host_configure' => 'Host-Login einrichten', 'host_configure' => 'Host-Login einrichten',
'servers_heading' => 'Server', 'servers_heading' => 'Server',
'search_placeholder' => 'Server suchen…', 'search_placeholder' => 'Server suchen…',
'search_clear' => 'Suche zurücksetzen',
'no_match' => 'Keine Treffer.', 'no_match' => 'Keine Treffer.',
'no_servers' => 'Noch keine Server angelegt.', 'no_servers' => 'Noch keine Server angelegt.',
'no_target' => 'Kein Ziel gewählt', 'no_target' => 'Kein Ziel gewählt',

View File

@ -13,6 +13,7 @@ return [
'host_configure' => 'Set up host login', 'host_configure' => 'Set up host login',
'servers_heading' => 'Servers', 'servers_heading' => 'Servers',
'search_placeholder' => 'Search servers…', 'search_placeholder' => 'Search servers…',
'search_clear' => 'Clear search',
'no_match' => 'No matches.', 'no_match' => 'No matches.',
'no_servers' => 'No servers added yet.', 'no_servers' => 'No servers added yet.',
'no_target' => 'No target selected', 'no_target' => 'No target selected',

View File

@ -24,6 +24,8 @@
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>', 'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>', 'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>', 'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'shield-check' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
'shield-off' => '<path d="M19.7 14a6.9 6.9 0 0 0 .3-2V5a1 1 0 0 0-1-1c-2 0-4.5-1.2-6.24-2.72a1.17 1.17 0 0 0-1.52 0c-.5.42-1.06.8-1.64 1.12"/><path d="M4.73 4.73 4 5v7c0 6 8 10 8 10a20.3 20.3 0 0 0 5.62-4.38"/><path d="m2 2 20 20"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>', 'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'logout' => '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>', 'logout' => '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>', 'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',

View File

@ -54,8 +54,8 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<a href="{{ route('settings') }}" wire:navigate class="flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors hover:bg-raised"> <a href="{{ route('settings') }}" wire:navigate class="flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors hover:bg-raised">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">{{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }}</span> <span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">{{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }}</span>
<span class="min-w-0 flex-1"> <span class="flex min-w-0 flex-1 items-center gap-1.5">
<span class="block truncate text-sm text-ink">{{ $u?->name ?? '—' }}</span> <span class="min-w-0 truncate text-sm text-ink">{{ $u?->name ?? '—' }}</span>
<x-two-factor-badge :enabled="$u?->hasTwoFactorEnabled() ?? false" /> <x-two-factor-badge :enabled="$u?->hasTwoFactorEnabled() ?? false" />
</span> </span>
</a> </a>

View File

@ -1,8 +1,12 @@
@props(['enabled' => false]) @props(['enabled' => false])
{{-- Status by colour, not a text badge: a cyan shield = 2FA on, a muted struck-through shield = off.
The label rides the title/aria-label (hover tooltip) so the meaning stays reachable. --}}
@if ($enabled) @if ($enabled)
<x-badge tone="cyan"> <span title="{{ __('shell.twofa_on') }}" aria-label="{{ __('shell.twofa_on') }}" class="shrink-0 text-cyan">
<x-icon name="shield" class="h-3 w-3" /> {{ __('settings.two_factor_on') }} <x-icon name="shield-check" class="h-4 w-4" />
</x-badge> </span>
@else @else
<x-badge tone="neutral">{{ __('settings.two_factor_off') }}</x-badge> <span title="{{ __('shell.twofa_off') }}" aria-label="{{ __('shell.twofa_off') }}" class="shrink-0 text-ink-4">
<x-icon name="shield-off" class="h-4 w-4" />
</span>
@endif @endif

View File

@ -35,10 +35,16 @@
<div class="my-1.5 px-3 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-4">{{ __('terminal.servers_heading') }}</div> <div class="my-1.5 px-3 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-4">{{ __('terminal.servers_heading') }}</div>
@if ($showSearch) @if ($showSearch)
<div class="relative px-1 pb-1"> <div class="relative px-1 pb-1.5">
<x-icon name="search" class="pointer-events-none absolute left-3.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" /> <x-icon name="search" class="pointer-events-none absolute left-3.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input wire:model.live.debounce.250ms="search" type="text" placeholder="{{ __('terminal.search_placeholder') }}" <input wire:model.live.debounce.200ms="search" type="search" autocomplete="off" placeholder="{{ __('terminal.search_placeholder') }}"
class="h-8 w-full rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-[12px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> class="h-8 w-full rounded-md border border-line bg-inset pl-8 pr-8 font-mono text-[12px] text-ink placeholder:text-ink-4 transition-colors focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/20 [&::-webkit-search-cancel-button]:appearance-none" />
@if ($search !== '')
<button type="button" wire:click="$set('search', '')" aria-label="{{ __('terminal.search_clear') }}"
class="absolute right-2.5 top-1/2 grid h-5 w-5 -translate-y-1/2 place-items-center rounded text-ink-4 transition-colors hover:bg-raised hover:text-ink-2">
<x-icon name="x" class="h-3 w-3" />
</button>
@endif
</div> </div>
@endif @endif

View File

@ -233,14 +233,12 @@ class TerminalTest extends TestCase
->assertDontSee('beta-db'); ->assertDontSee('beta-db');
} }
public function test_search_box_appears_only_past_the_threshold(): void public function test_search_box_appears_once_there_is_more_than_one_server(): void
{ {
$this->get(route('terminal'))->assertDontSee(__('terminal.search_placeholder')); Server::create(['name' => 'only-one', 'ip' => '10.0.0.1', 'status' => 'online']);
$this->get(route('terminal'))->assertDontSee(__('terminal.search_placeholder')); // 1 server → no search
foreach (range(1, 6) as $i) { Server::create(['name' => 'second', 'ip' => '10.0.0.2', 'status' => 'online']);
Server::create(['name' => 'srv-'.$i, 'ip' => '10.0.0.'.$i, 'status' => 'online']); $this->get(route('terminal'))->assertSee(__('terminal.search_placeholder')); // 2 servers → search shows
}
$this->get(route('terminal'))->assertSee(__('terminal.search_placeholder'));
} }
} }