feat(rbac): hide gated controls in the UI with @can (operator/viewer see no dead buttons)

feat/v1-foundation
boban 2026-07-05 01:49:38 +02:00
parent 4f9699d470
commit 8b34ed8ef5
10 changed files with 362 additions and 170 deletions

View File

@ -33,7 +33,9 @@
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item> <x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item> <x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item> <x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
@can('manage-network')
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item> <x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
@endcan
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item> <x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p> <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>

View File

@ -49,12 +49,14 @@
{{-- Listing --}} {{-- Listing --}}
<x-panel :title="$path" :subtitle="__('files.panel_subtitle', ['dirs' => $dirs, 'files' => $files])" :padded="false"> <x-panel :title="$path" :subtitle="__('files.panel_subtitle', ['dirs' => $dirs, 'files' => $files])" :padded="false">
<x-slot:actions> <x-slot:actions>
@can('operate')
<label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15"> <label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" /> <x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" />
<svg wire:loading wire:target="upload" 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> <svg wire:loading wire:target="upload" 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>
{{ __('files.upload') }} {{ __('files.upload') }}
<input type="file" wire:model="upload" class="hidden" /> <input type="file" wire:model="upload" class="hidden" />
</label> </label>
@endcan
</x-slot:actions> </x-slot:actions>
{{-- Column header (wide desktop only below xl the rows render as stacked cards) --}} {{-- Column header (wide desktop only below xl the rows render as stacked cards) --}}
@ -132,7 +134,9 @@
<x-btn variant="secondary" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn> <x-btn variant="secondary" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
<x-modal-trigger variant="secondary" action="edit({{ $loop->index }})">{{ __('common.edit') }}</x-modal-trigger> <x-modal-trigger variant="secondary" action="edit({{ $loop->index }})">{{ __('common.edit') }}</x-modal-trigger>
@endunless @endunless
@can('operate')
<x-modal-trigger variant="danger-soft" action="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-modal-trigger> <x-modal-trigger variant="danger-soft" action="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-modal-trigger>
@endcan
</div> </div>
</div> </div>
@endforeach @endforeach

View File

@ -31,10 +31,12 @@
<div class="mt-5 flex items-center justify-end gap-2"> <div class="mt-5 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn> <x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
@if ($loaded && ! $error && ! $tooBig && ! $binary && ! $this->isImage()) @if ($loaded && ! $error && ! $tooBig && ! $binary && ! $this->isImage())
@can('operate')
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled"> <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> <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>
{{ __('common.save') }} {{ __('common.save') }}
</x-btn> </x-btn>
@endcan
@endif @endif
</div> </div>
</div> </div>

View File

@ -10,10 +10,12 @@
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('servers.heading') }}</h2> <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('servers.heading') }}</h2>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@can('manage-fleet')
<x-modal-trigger variant="accent" component="modals.create-server"> <x-modal-trigger variant="accent" component="modals.create-server">
<x-icon name="plus" class="h-3.5 w-3.5" /> <x-icon name="plus" class="h-3.5 w-3.5" />
{{ __('servers.add_server') }} {{ __('servers.add_server') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
<x-status-pill status="online">{{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}</x-status-pill> <x-status-pill status="online">{{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}</x-status-pill>
</div> </div>
</div> </div>

View File

@ -117,6 +117,7 @@
<span class="text-ink-2">{{ $cred->username }}</span>@<span>{{ $server->ip }}</span> <span class="text-ink-2">{{ $cred->username }}</span>@<span>{{ $server->ip }}</span>
</p> </p>
</div> </div>
@can('manage-fleet')
<div class="flex shrink-0 flex-wrap items-center gap-2"> <div class="flex shrink-0 flex-wrap items-center gap-2">
<x-modal-trigger variant="secondary" component="modals.edit-credential" :arguments="['serverId' => $server->id]"> <x-modal-trigger variant="secondary" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
<x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }} <x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }}
@ -130,6 +131,7 @@
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }} <x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }}
</x-modal-trigger> </x-modal-trigger>
</div> </div>
@endcan
</div> </div>
@else @else
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5"> <div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
@ -140,9 +142,11 @@
<p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p> <p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p>
<p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p> <p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p>
</div> </div>
@can('manage-fleet')
<x-modal-trigger variant="accent" class="shrink-0" component="modals.edit-credential" :arguments="['serverId' => $server->id]"> <x-modal-trigger variant="accent" class="shrink-0" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }} <x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
</div> </div>
@endif @endif
</x-panel> </x-panel>
@ -307,23 +311,29 @@
</div> </div>
@if ($supported) @if ($supported)
@if ($check['key'] === 'fail2ban') @if ($check['key'] === 'fail2ban')
@can('manage-network')
<x-modal-trigger variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}" <x-modal-trigger variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]"> component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
<x-icon name="settings" class="h-3.5 w-3.5" /> <x-icon name="settings" class="h-3.5 w-3.5" />
</x-modal-trigger> </x-modal-trigger>
@endcan
@endif @endif
@if ($check['key'] === 'ssh_password' && $check['featureOn']) @if ($check['key'] === 'ssh_password' && $check['featureOn'])
{{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}} {{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}}
@can('manage-fleet')
<x-modal-trigger variant="secondary" size="sm" class="shrink-0" <x-modal-trigger variant="secondary" size="sm" class="shrink-0"
component="modals.ssh-key-provision" :arguments="['serverId' => $server->id]"> component="modals.ssh-key-provision" :arguments="['serverId' => $server->id]">
{{ __('servers.ssh_key_provision_action') }} {{ __('servers.ssh_key_provision_action') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
@else @else
@can('manage-network')
<x-modal-trigger variant="secondary" size="sm" class="shrink-0" <x-modal-trigger variant="secondary" size="sm" class="shrink-0"
component="modals.hardening-action" component="modals.hardening-action"
:arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]"> :arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]">
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }} {{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
@endif @endif
@endif @endif
</div> </div>
@ -386,12 +396,14 @@
:subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))" :subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))"
:padded="false"> :padded="false">
@if ($fwManageable) @if ($fwManageable)
@can('manage-network')
<x-slot:actions> <x-slot:actions>
<x-modal-trigger variant="accent" size="sm" <x-modal-trigger variant="accent" size="sm"
component="modals.firewall-rule" :arguments="['serverId' => $server->id, 'tool' => $fwTool]"> 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-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.firewall_add_rule') }}
</x-modal-trigger> </x-modal-trigger>
</x-slot:actions> </x-slot:actions>
@endcan
@endif @endif
@if ($fwReadError) @if ($fwReadError)
@ -402,10 +414,12 @@
@elseif ($fwTool === 'firewalld' && ! $fwActive) @elseif ($fwTool === 'firewalld' && ! $fwActive)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5"> <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> <p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewalld_inactive') }}</p>
@can('manage-network')
<x-modal-trigger variant="secondary" size="sm" <x-modal-trigger variant="secondary" size="sm"
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]"> component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]">
{{ __('common.enable') }} {{ __('common.enable') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
</div> </div>
@else @else
{{-- Lone firewall: defaults/state in the left column, rules list in the {{-- Lone firewall: defaults/state in the left column, rules list in the
@ -456,10 +470,12 @@
'text-offline' => $ruleTone === 'offline', 'text-offline' => $ruleTone === 'offline',
])>{{ strtolower($rule['action'] ?? 'allow') }}</span> ])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
@unless ($fwReadOnly) @unless ($fwReadOnly)
@can('manage-network')
<x-modal-trigger variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}" <x-modal-trigger variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
action="confirmDeleteRule({{ $loop->index }})"> action="confirmDeleteRule({{ $loop->index }})">
<x-icon name="trash" class="h-3.5 w-3.5" /> <x-icon name="trash" class="h-3.5 w-3.5" />
</x-modal-trigger> </x-modal-trigger>
@endcan
@endunless @endunless
</div> </div>
@empty @empty
@ -479,6 +495,7 @@
:subtitle="$f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')" :subtitle="$f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')"
:padded="false"> :padded="false">
@if ($f2bActive) @if ($f2bActive)
@can('manage-network')
<x-slot:actions> <x-slot:actions>
<x-modal-trigger variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}" <x-modal-trigger variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]"> component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
@ -490,6 +507,7 @@
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }} <x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }}
</x-modal-trigger> </x-modal-trigger>
</x-slot:actions> </x-slot:actions>
@endcan
@endif @endif
@if ($f2bReadError) @if ($f2bReadError)
@ -500,10 +518,12 @@
@elseif (! $f2bActive) @elseif (! $f2bActive)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5"> <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> <p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_state_installed_inactive') }}</p>
@can('manage-network')
<x-modal-trigger variant="secondary" size="sm" <x-modal-trigger variant="secondary" size="sm"
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'fail2ban', 'enable' => true]"> component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'fail2ban', 'enable' => true]">
{{ __('common.enable') }} {{ __('common.enable') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
</div> </div>
@else @else
{{-- Lone fail2ban: status summary + "Gesperrte IPs ansehen" in the left {{-- Lone fail2ban: status summary + "Gesperrte IPs ansehen" in the left
@ -522,10 +542,12 @@
· <span @class(['text-offline' => $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }}</span> · <span @class(['text-offline' => $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }}</span>
· {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }} · {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }}
</p> </p>
@can('manage-network')
<x-modal-trigger variant="secondary" size="sm" class="shrink-0" <x-modal-trigger variant="secondary" size="sm" class="shrink-0"
component="modals.fail2ban-bans" :arguments="['serverId' => $server->id]"> component="modals.fail2ban-bans" :arguments="['serverId' => $server->id]">
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_view_bans') }} <x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_view_bans') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
</div> </div>
{{-- Whitelist (ignoreip) --}} {{-- Whitelist (ignoreip) --}}
@ -540,18 +562,22 @@
<span wire:key="f2bi-{{ md5($ip) }}" 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"> <span wire:key="f2bi-{{ md5($ip) }}" 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 }} {{ $ip }}
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true)) @unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
@can('manage-network')
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}"> <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" /> <x-icon name="x" class="h-3 w-3" />
</button> </button>
@endcan
@endunless @endunless
</span> </span>
@endforeach @endforeach
</div> </div>
@can('manage-network')
<div class="mt-2.5 flex items-center gap-2"> <div class="mt-2.5 flex items-center gap-2">
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="{{ __('servers.whitelist_placeholder') }}" <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" /> 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> <x-btn variant="secondary" size="sm" wire:click="addIgnore">{{ __('common.add') }}</x-btn>
</div> </div>
@endcan
</div> </div>
</div> </div>
@endif @endif
@ -616,11 +642,13 @@
{{-- SSH keys --}} {{-- SSH keys --}}
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false"> <x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false">
@can('manage-fleet')
<x-slot:actions> <x-slot:actions>
<x-modal-trigger variant="accent" component="modals.add-ssh-key" :arguments="['serverId' => $server->id]"> <x-modal-trigger variant="accent" component="modals.add-ssh-key" :arguments="['serverId' => $server->id]">
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }} <x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }}
</x-modal-trigger> </x-modal-trigger>
</x-slot:actions> </x-slot:actions>
@endcan
<div class="divide-y divide-line"> <div class="divide-y divide-line">
@forelse ($sshKeys as $key) @forelse ($sshKeys as $key)
<div wire:key="sshk-{{ md5($key['fingerprint'] ?? $loop->index) }}" class="flex items-center gap-3 px-4 py-3 sm:px-5"> <div wire:key="sshk-{{ md5($key['fingerprint'] ?? $loop->index) }}" class="flex items-center gap-3 px-4 py-3 sm:px-5">
@ -634,9 +662,11 @@
</div> </div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p> <p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div> </div>
@can('manage-fleet')
<x-modal-trigger variant="danger-soft" icon action="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}"> <x-modal-trigger variant="danger-soft" icon action="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
<x-icon name="trash" class="h-3.5 w-3.5" /> <x-icon name="trash" class="h-3.5 w-3.5" />
</x-modal-trigger> </x-modal-trigger>
@endcan
</div> </div>
@empty @empty
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p> <p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p>

View File

@ -72,11 +72,13 @@
<x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill> <x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill>
{{-- R5: wire to wire-elements/modal --}} {{-- R5: wire to wire-elements/modal --}}
@can('operate')
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<x-modal-trigger variant="secondary" action="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-modal-trigger> <x-modal-trigger variant="secondary" action="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-modal-trigger>
<x-modal-trigger variant="secondary" action="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-modal-trigger> <x-modal-trigger variant="secondary" action="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-modal-trigger>
<x-modal-trigger variant="accent" action="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-modal-trigger> <x-modal-trigger variant="accent" action="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-modal-trigger>
</div> </div>
@endcan
</div> </div>
</div> </div>
@empty @empty

View File

@ -39,9 +39,11 @@
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0" />{{ __('system.restart_running') }} <x-icon name="rotate" class="h-3.5 w-3.5 shrink-0" />{{ __('system.restart_running') }}
</p> </p>
@else @else
@can('manage-panel')
<x-btn variant="primary" wire:click="restartNow" wire:loading.attr="disabled" wire:target="restartNow" class="shrink-0"> <x-btn variant="primary" wire:click="restartNow" wire:loading.attr="disabled" wire:target="restartNow" class="shrink-0">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('system.restart_now') }} <x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('system.restart_now') }}
</x-btn> </x-btn>
@endcan
@endif @endif
</div> </div>
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_watcher_hint') }}</p> <p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_watcher_hint') }}</p>
@ -79,9 +81,11 @@
autocapitalize="off" spellcheck="false" autocapitalize="off" spellcheck="false"
placeholder="{{ __('system.domain_placeholder') }}" placeholder="{{ __('system.domain_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" /> 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" />
@can('manage-panel')
<x-modal-trigger variant="primary" action="confirmDomain" class="shrink-0"> <x-modal-trigger variant="primary" action="confirmDomain" class="shrink-0">
<x-icon name="save" class="h-3.5 w-3.5" /> {{ __('common.save') }} <x-icon name="save" class="h-3.5 w-3.5" /> {{ __('common.save') }}
</x-modal-trigger> </x-modal-trigger>
@endcan
</div> </div>
@error('domainInput') @error('domainInput')
<p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p> <p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p>
@ -94,6 +98,7 @@
<p class="text-sm font-medium text-ink-2">{{ __('system.tls_mode_title') }}</p> <p class="text-sm font-medium text-ink-2">{{ __('system.tls_mode_title') }}</p>
{{-- Segmented control --}} {{-- Segmented control --}}
@can('manage-panel')
<div role="radiogroup" aria-label="{{ __('system.tls_mode_title') }}" class="flex w-full rounded-md border border-line bg-inset p-1"> <div role="radiogroup" aria-label="{{ __('system.tls_mode_title') }}" class="flex w-full rounded-md border border-line bg-inset p-1">
<button type="button" <button type="button"
role="radio" role="radio"
@ -128,6 +133,7 @@
<span x-show="!pending" class="contents">@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}</span> <span x-show="!pending" class="contents">@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}</span>
</button> </button>
</div> </div>
@endcan
{{-- Per-mode description --}} {{-- Per-mode description --}}
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3"> <div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
@ -211,12 +217,14 @@
<span class="text-ink-3">{{ __('system.cert_status_unknown') }}</span> <span class="text-ink-3">{{ __('system.cert_status_unknown') }}</span>
@endswitch @endswitch
</p> </p>
@can('manage-panel')
<x-btn variant="secondary" size="sm" class="mt-2.5" <x-btn variant="secondary" size="sm" class="mt-2.5"
wire:click="requestCertificate" wire:target="requestCertificate" wire:loading.attr="disabled"> wire:click="requestCertificate" wire:target="requestCertificate" wire:loading.attr="disabled">
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestCertificate" /> <x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestCertificate" />
<svg wire:loading wire:target="requestCertificate" 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> <svg wire:loading wire:target="requestCertificate" 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>
{{ __('system.cert_button') }} {{ __('system.cert_button') }}
</x-btn> </x-btn>
@endcan
</div> </div>
@elseif ($tlsMode === 'external') @elseif ($tlsMode === 'external')
<div class="rounded-md border border-line bg-inset/60 p-3"> <div class="rounded-md border border-line bg-inset/60 p-3">

View File

@ -11,6 +11,7 @@
<x-panel :title="__('terminal.targets_title')" :subtitle="__('terminal.targets_subtitle')" :padded="false"> <x-panel :title="__('terminal.targets_title')" :subtitle="__('terminal.targets_subtitle')" :padded="false">
<div class="space-y-1 p-2"> <div class="space-y-1 p-2">
{{-- Clusev host a real SSH login into the host machine (configured by the operator). --}} {{-- Clusev host a real SSH login into the host machine (configured by the operator). --}}
@can('manage-fleet')
<div @class([ <div @class([
'flex items-center rounded-md border transition-colors', 'flex items-center rounded-md border transition-colors',
'border-accent/30 bg-accent/10' => $activeKey === 'host', 'border-accent/30 bg-accent/10' => $activeKey === 'host',
@ -31,6 +32,7 @@
<x-icon name="settings" class="h-3.5 w-3.5" /> <x-icon name="settings" class="h-3.5 w-3.5" />
</button> </button>
</div> </div>
@endcan
<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>

View File

@ -79,11 +79,13 @@
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }} <x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }}
</p> </p>
@elseif ($isUpdate) @elseif ($isUpdate)
@can('manage-panel')
<x-btn variant="primary" wire:click="requestUpdate" wire:target="requestUpdate" wire:loading.attr="disabled" class="w-full sm:w-auto sm:shrink-0"> <x-btn variant="primary" wire:click="requestUpdate" wire:target="requestUpdate" wire:loading.attr="disabled" class="w-full sm:w-auto sm:shrink-0">
<svg wire:loading wire:target="requestUpdate" 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> <svg wire:loading wire:target="requestUpdate" 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>
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" /> <x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" />
{{ __('versions.update_now') }} {{ __('versions.update_now') }}
</x-btn> </x-btn>
@endcan
@endif @endif
@if ($lastChecked) @if ($lastChecked)
<span class="font-mono text-[11px] text-ink-4 sm:shrink-0">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span> <span class="font-mono text-[11px] text-ink-4 sm:shrink-0">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>

View File

@ -0,0 +1,138 @@
<?php
namespace Tests\Feature;
use App\Livewire\Servers\Index as Servers;
use App\Livewire\Services\Index as Services;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* RBAC view-level HIDING: operators/viewers must not SEE the controls they would be
* 403'd from (the server-side abort_unless guards are proven in the Rbac*GateTest
* suites). This only asserts the `@can(...)` wrappers render/omit the button markup
* a light UX check across a few representative surfaces, one per ability family:
* - Servers\Index add-server trigger manage-fleet (admin sees, viewer does not);
* - Services\Index start/stop/restart operate (operator sees, viewer does not);
* - the sidebar WireGuard nav item manage-network(admin sees, viewer does not).
* Every SSH read is mocked no real host is ever touched.
*/
class RbacUiHidingTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
/** An online server with a credential, selected as the active fleet server. */
private function activeServer(): Server
{
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
session(['active_server_id' => $server->id]);
return $server;
}
// ── manage-fleet: Servers\Index add-server trigger ──────────────────────────
public function test_admin_sees_the_add_server_trigger(): void
{
$this->actingAs($this->admin());
Livewire::test(Servers::class)
->assertOk()
->assertSee(__('servers.add_server')); // "Server hinzufügen"
}
public function test_viewer_does_not_see_the_add_server_trigger(): void
{
$this->actingAs($this->viewer());
Livewire::test(Servers::class)
->assertOk() // the fleet list still renders
->assertDontSee(__('servers.add_server')); // but the create control is gone
}
// ── operate: Services\Index start/stop/restart action buttons ───────────────
/** Stubs the systemd read so the list renders with one service row. */
private function stubServices(): void
{
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('systemd')->andReturn([
'services' => [['name' => 'nginx.service', 'status' => 'offline', 'sub' => 'dead', 'enabled' => true, 'desc' => 'web']],
'journal' => [],
'cursor' => null,
]);
app()->instance(FleetService::class, $fleet);
}
public function test_operator_sees_a_service_action_control(): void
{
$this->actingAs($this->operator());
$this->activeServer();
$this->stubServices();
Livewire::test(Services::class)
->call('load')
->assertOk()
->assertSee(__('services.restart')); // "Neustart" — a per-row action button
}
public function test_viewer_does_not_see_a_service_action_control(): void
{
$this->actingAs($this->viewer());
$this->activeServer();
$this->stubServices();
Livewire::test(Services::class)
->call('load')
->assertOk()
->assertSee('nginx.service') // the list itself is visible
->assertDontSee(__('services.restart')); // but the action buttons are gone
}
// ── manage-network: sidebar WireGuard nav item ──────────────────────────────
public function test_admin_sees_the_wireguard_nav(): void
{
$this->actingAs($this->admin());
$this->get('/servers')
->assertOk()
->assertSee(__('shell.nav_wireguard')); // "WireGuard" nav item present
}
public function test_viewer_does_not_see_the_wireguard_nav(): void
{
$this->actingAs($this->viewer());
$this->get('/servers')
->assertOk()
->assertDontSee(__('shell.nav_wireguard')); // hidden for a viewer
}
}