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
parent
2b1e98c5d4
commit
00a9d8b9a3
|
|
@ -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 = '';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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"/>',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue