CluPilotCloud/resources/views/livewire/admin/settings.blade.php

457 lines
30 KiB
PHP

<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
</div>
{{-- Version & update --}}
@if ($canManageSite)
{{-- Polls itself: an operator watching an update run should not have to
reload the page to find out whether it finished. Fast while
something is happening, slow when nothing is.
wire:poll alone is not enough, because the thing being watched
restarts the thing doing the watching. Mid-run the requests fail,
and what answers afterwards is a NEW build being questioned by the
old page's JavaScript — so the card sat on "läuft" until somebody
reloaded. That gap is now covered by the full-screen overlay in
layouts/admin.blade.php, which binds the same watcher once for
every console page rather than here — this card no longer binds
it itself, so the two do not double-poll while this page is
open. wire:poll below is unrelated and stays: it is Livewire's
own refresh of this card's own details (log tail, elapsed time)
for as long as a Livewire connection exists. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ ($update['running'] || $update['checking']) ? '3s' : '30s' }}>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.update_title') }}</h2>
@if ($update['running'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
</span>
@elseif ($update['checking'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_checking') }}
</span>
@elseif ($update['available'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{-- The version, when it is known: "Version 1.1.0
verfügbar" is a thing an operator can decide
about; "1 Aktualisierung verfügbar" is not. --}}
@if ($update['target_release'])
{{ __('admin_settings.update_available_release', ['version' => ltrim($update['target_release'], 'v')]) }}
@else
{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
@endif
</span>
@elseif ($update['behind'] === 0)
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_current') }}
</span>
@else
{{-- Never shown as "up to date": not knowing and being
current are different answers. --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border border-line-strong bg-surface-2 px-2.5 py-0.5 text-xs font-medium text-muted">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_unknown') }}
</span>
@endif
</div>
<dl class="mt-3 space-y-1 text-sm">
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_version') }}:</dt>
<dd class="font-mono text-body">{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif</dd></div>
@if ($update['source'])
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_source') }}:</dt>
<dd class="font-mono text-body">{{ $update['source'] }}</dd></div>
@endif
@if ($update['deployed_at'])
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_deployed') }}:</dt>
<dd class="text-body">{{ $update['deployed_at']->diffForHumans() }}</dd></div>
@endif
@if ($update['checked_at'])
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_checked') }}:</dt>
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
@endif
</dl>
</div>
{{-- Bound, not @disabled(): the directive compiles to inline PHP
inside the component tag, and Blade then stops seeing a
component.
Two separate actions, always offered together: looking and
applying are different intentions, and an operator who only
wants to know whether anything is new must not be routed
through a real deployment to find out.
"Jetzt aktualisieren" now requires a released version to
install. It used to be offered unconditionally, because
`behind` was a reading taken up to a minute ago and a
console that had not noticed the last commit yet would
refuse to act on it. That reasoning belonged to counting
commits on a branch, where the answer changes several times
an hour. An update is a TAG now: it appears when somebody
releases one, not when somebody pushes — and pressing the
check button answers within a second, so a stale reading is
no longer something to work around.
Disabled for the cases where it would go nowhere: nothing
released above the installed version, a run already in
flight, no agent, or a check already pending (so a second
click cannot collide with the first and be refused). --}}
<div class="flex shrink-0 items-center gap-2">
@if ($update['running'])
<x-ui.button variant="secondary" :disabled="true">
<x-ui.icon name="refresh" class="size-4 animate-spin" />
{{ __('admin_settings.update_running') }}
</x-ui.button>
@elseif (! $update['agent_seen'])
<x-ui.button variant="secondary" :disabled="true">{{ __('admin_settings.update_unknown') }}</x-ui.button>
@else
<x-ui.button variant="secondary" :disabled="$update['checking']"
wire:click="requestCheck" wire:loading.attr="disabled" wire:target="requestCheck">
<x-ui.icon name="refresh" class="size-4 {{ $update['checking'] ? 'animate-spin' : '' }}" />
{{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }}
</x-ui.button>
<x-ui.button variant="primary" :disabled="$update['checking'] || ! $update['available']"
wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
<x-ui.icon name="download" class="size-4" />
{{ __('admin_settings.update_now') }}
</x-ui.button>
@endif
</div>
</div>
@if (! $update['agent_seen'])
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
@elseif ($update['last_error'])
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
@elseif (! $update['available'] && ! $update['running'] && ! $update['checking'])
{{-- Why the button is grey. Without this the console looks
broken to whoever just pushed to main and expects to see
something: it is not broken, it is waiting for a release. --}}
<p class="mt-3 text-xs text-muted">{{ __('admin_settings.update_only_releases') }}</p>
@endif
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
the shell: a queued update does nothing visible until the agent
picks it up, and while it runs the site is in maintenance mode
and this page is unreachable. Both look identical to "wedged"
without a time — hence the live countdown, replacing what used
to be a static "spätestens um 17:19" that read as a promise
nothing then visibly kept.
There is no countdown any more, for either. It was wrong in
fact — the target was recomputed on every poll, and where the
interval the agent reported was shorter than the timer actually
installed on that host it landed in the past every time and
reset to a full minute, so it ran backwards a few seconds and
jumped, forever. And it was wrong in kind: a check starts
nothing, and an update now starts at once. A queued run says so
in a sentence; a queued check says nothing beyond its badge. --}}
@if ($update['running'])
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
@if ($update['phase'])
<p class="font-medium text-body">{{ __('admin_settings.update_step', ['step' => $update['phase']]) }}</p>
@if ($update['started_at'])
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
@endif
@else
<p class="font-medium text-body">
{{ $update['instant'] ? __('admin_settings.update_starting') : __('admin_settings.update_starting_queued') }}
</p>
@endif
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
</div>
@endif
@if ($update['last_state'] !== null && ! $update['running'])
<p class="mt-3 text-xs text-muted">
{{ __('admin_settings.update_last_run', [
'state' => __('admin_settings.update_state.'.$update['last_state']),
'when' => $update['last_finished_at']?->diffForHumans() ?? '',
]) }}
@if ($update['last_started_at'] && $update['last_finished_at'])
· {{ __('admin_settings.update_took', ['duration' => $update['last_started_at']->diffForHumans($update['last_finished_at'], true)]) }}
@endif
{{-- Only on a failure: on a run that worked the last step is
"left maintenance mode", which says nothing. --}}
@if ($update['last_state'] === 'failed' && $update['last_phase'])
· {{ __('admin_settings.update_failed_at', ['step' => $update['last_phase']]) }}
@endif
</p>
@endif
{{-- Open while it runs: "läuft gerade" on its own tells an operator
nothing about whether it is progressing or wedged. --}}
@if ($updateLog)
<details class="mt-4" @if ($update['running']) open @endif>
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
</details>
@endif
</div>
@endif
{{-- My account --}}
@if ($canManageSite)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.site_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $sitePublic ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }}
</span>
</div>
<p class="mt-1.5 max-w-xl text-sm text-muted">
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
</p>
<p class="mt-2 text-xs text-muted">
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
</span>
</p>
</div>
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled" wire:target="toggleSiteVisibility">
<x-ui.icon :name="$sitePublic ? 'lock' : 'unlock'" class="size-4" />
{{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }}
</x-ui.button>
</div>
</div>
@endif
{{-- Who may reach this console --}}
@if ($canManageSite)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.console_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $consoleRestricted ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }}
</span>
</div>
<p class="mt-1 max-w-xl text-sm text-muted">
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
</p>
<p class="mt-2 font-mono text-xs text-muted">
{{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}
</p>
</div>
<x-ui.button :variant="$consoleRestricted ? 'secondary' : 'primary'"
wire:click="toggleConsoleRestriction" wire:loading.attr="disabled" wire:target="toggleConsoleRestriction">
<x-ui.icon :name="$consoleRestricted ? 'unlock' : 'lock'" class="size-4" />
{{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }}
</x-ui.button>
</div>
<div class="mt-5 space-y-2 border-t border-line pt-4">
<p class="text-xs font-semibold text-muted">{{ __('admin_settings.console_always') }}</p>
@foreach ($consoleVpnRanges as $range)
<div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
<span class="font-mono text-sm text-body">{{ $range }}</span>
<span class="text-xs text-muted">{{ __('admin_settings.console_vpn_note') }}</span>
</div>
@endforeach
<p class="pt-3 text-xs font-semibold text-muted">{{ __('admin_settings.console_extra') }}</p>
@forelse ($consoleIps as $ip)
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
<span class="font-mono text-sm text-body">{{ $ip }}</span>
<x-ui.button variant="ghost" size="sm" wire:click="removeConsoleIp('{{ $ip }}')">
{{ __('admin_settings.console_remove') }}
</x-ui.button>
</div>
@empty
<p class="text-sm text-muted">{{ __('admin_settings.console_none') }}</p>
@endforelse
{{-- The hint sits under the row, not inside it: as a sibling of
the field it made the flex row as tall as field-plus-hint,
and the button stretched to match. --}}
<form wire:submit="addConsoleIp" class="pt-2">
<div class="flex items-start gap-2">
<div class="flex-1">
<x-ui.input name="consoleIp" wire:model="consoleIp" placeholder="203.0.113.7" />
</div>
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
</div>
<p class="mt-1.5 text-xs text-muted">{{ __('admin_settings.console_ip_hint') }}</p>
</form>
</div>
</div>
@endif
{{-- Every operator sees their own, with no capability required: this is
the account you are signed in as, and being unable to see where else
that is true is the gap the feature exists to close. --}}
<div class="animate-rise [animation-delay:40ms]">
@livewire('admin.sessions')
</div>
{{-- Two-factor policy — voluntary by default, the Owner can make it
compulsory. The switch refuses to turn on while the operator flipping
it has no confirmed two-factor themselves: the page that would turn it
back off sits behind the switch. --}}
@if ($canManageSite)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:45ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.two_factor_title') }}</h2>
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('admin_settings.two_factor_body') }}</p>
<form wire:submit="saveTwoFactorPolicy" class="mt-4 flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-2 text-sm text-body">
<input type="checkbox" wire:model="requireTwoFactor" class="size-4 rounded border-line-strong text-accent" />
{{ __('admin_settings.two_factor_require') }}
</label>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="saveTwoFactorPolicy">
{{ __('admin_settings.save') }}
</x-ui.button>
</form>
@error('requireTwoFactor')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@endif
{{-- Own password. There was no way to change one at all: an account created
with a generated password kept it until someone opened a shell. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
:label="__('admin_settings.password_new')" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
<div class="flex justify-end">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
</div>
</form>
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
</div>
<div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
</div>
</form>
{{-- Staff & access (Owner-only) --}}
@if ($canManageStaff)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.staff_sub') }}</p>
</div>
{{-- Invite --}}
<form wire:submit="inviteStaff" class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div>
<label class="text-sm font-medium text-body" for="staffName">{{ __('admin_settings.name') }}</label>
<input id="staffName" type="text" wire:model="staffName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('staffName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="staffEmail">{{ __('admin_settings.email') }}</label>
<input id="staffEmail" type="email" wire:model="staffEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('staffEmail')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="staffRole">{{ __('admin_settings.role') }}</label>
<select id="staffRole" wire:model="staffRole" class="mt-1.5 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($roles as $r)
@if ($r !== 'Owner' || $isOwner)
<option value="{{ $r }}">{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($r)) }}</option>
@endif
@endforeach
</select>
@error('staffRole')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="inviteStaff">
<x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }}
</x-ui.button>
</form>
<p class="text-xs text-muted">{{ __('admin_settings.invite_hint') }}</p>
@if ($invitedPassword)
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-semibold text-ink">{{ __('admin_settings.temp_title') }}</p>
<p class="mt-1 text-xs text-body">{{ __('admin_settings.temp_hint') }}</p>
<dl class="mt-2 space-y-1 font-mono text-sm text-ink">
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.email') }}:</dt><dd>{{ $invitedEmail }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.temp_password') }}:</dt><dd class="select-all">{{ $invitedPassword }}</dd></div>
</dl>
</div>
@endif
{{-- Staff list --}}
<div class="overflow-hidden rounded-lg border border-line">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.role') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('admin_settings.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($staff as $s)
<tr wire:key="staff-{{ $s['id'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $s['name'] }}
@if ($s['self'])<span class="ml-1 text-xs text-muted">({{ __('admin_settings.you') }})</span>@endif
</p>
<p class="text-xs text-muted">{{ $s['email'] }}</p>
</td>
<td class="px-4 py-3">
@if ($s['self'])
<span class="text-body">{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($s['role'])) }}</span>
@else
<select wire:change="setStaffRole({{ $s['id'] }}, $event.target.value)" class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
@foreach ($roles as $r)
@if ($r !== 'Owner' || $isOwner)
<option value="{{ $r }}" @selected($s['role'] === $r)>{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($r)) }}</option>
@endif
@endforeach
</select>
@endif
</td>
<td class="px-4 py-3 text-right">
{{-- Your own row has no actions — you cannot revoke
yourself — and an empty cell reads as a bug rather
than as a rule. --}}
@if ($s['self'])
<span class="text-xs text-muted">—</span>
@endif
@unless ($s['self'])
<button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button>
@endunless
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
</div>