553 lines
38 KiB
PHP
553 lines
38 KiB
PHP
<div class="mx-auto max-w-[1120px] space-y-10">
|
|
<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>
|
|
|
|
{{-- ── Die Installation ─────────────────────────────────────────────────
|
|
Grouped and given headings because the page was one undifferentiated
|
|
stack: eight cards of the same weight in a column half the window wide,
|
|
with the operator's own password sitting between two platform-wide
|
|
switches. What each card belongs to is now said out loud, and the short
|
|
ones sit side by side instead of each taking a full row to hold three
|
|
lines of text. --}}
|
|
<section class="space-y-5">
|
|
<div class="flex items-center gap-4">
|
|
<h2 class="lbl">{{ __('admin_settings.group.installation') }}</h2>
|
|
<span class="h-px flex-1 bg-line"></span>
|
|
</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. --}}
|
|
{{-- And the step is NOT shown here.
|
|
|
|
It used to be, in a small bordered block inside this card, and
|
|
the moment a run began an operator saw it appear and then, up
|
|
to three seconds later, saw the full-page overlay cover it and
|
|
say the same thing in a different size. Two windows for one
|
|
event. The overlay in layouts/admin carries the step, the
|
|
running time and the log tail — it is the one that survives the
|
|
restart, so it is the one that keeps them. --}}
|
|
|
|
{{-- The window. Deliberately inside the update card rather than a
|
|
section of its own: it is the same decision as the button next
|
|
to it, only taken in advance. --}}
|
|
<div class="mt-5 border-t border-line pt-5">
|
|
<label class="flex items-start gap-3">
|
|
<input type="checkbox" wire:model.live="autoUpdate" class="mt-0.5 size-4 rounded border-line text-accent-active">
|
|
<span>
|
|
<span class="block text-sm font-semibold text-ink">{{ __('admin_settings.auto_title') }}</span>
|
|
<span class="mt-1 block max-w-[64ch] text-xs leading-relaxed text-muted">{{ __('admin_settings.auto_hint') }}</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div class="mt-4 space-y-4 @unless ($autoUpdate) opacity-50 @endunless">
|
|
<div>
|
|
<span class="mb-2 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_days') }}</span>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
@foreach ([1, 2, 3, 4, 5, 6, 7] as $day)
|
|
<label class="cursor-pointer">
|
|
<input type="checkbox" wire:model="autoDays" value="{{ $day }}" class="peer sr-only" @disabled(! $autoUpdate)>
|
|
<span class="inline-flex min-h-9 items-center rounded border border-line bg-surface px-3 text-sm text-muted transition peer-checked:border-accent-border peer-checked:bg-accent-subtle peer-checked:font-semibold peer-checked:text-accent-text">
|
|
{{ __('admin_settings.weekday.'.$day) }}
|
|
</span>
|
|
</label>
|
|
@endforeach
|
|
</div>
|
|
@error('autoDays') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<label for="auto-from" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_from') }}</label>
|
|
<input id="auto-from" type="time" wire:model="autoFrom" @disabled(! $autoUpdate)
|
|
class="min-h-10 w-32 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
|
|
@error('autoFrom') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
|
</div>
|
|
<div>
|
|
<label for="auto-to" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_to') }}</label>
|
|
<input id="auto-to" type="time" wire:model="autoTo" @disabled(! $autoUpdate)
|
|
class="min-h-10 w-32 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
|
|
@error('autoTo') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
|
|
</div>
|
|
<x-ui.button wire:click="saveUpdateWindow" variant="secondary" size="sm">{{ __('admin_settings.save') }}</x-ui.button>
|
|
</div>
|
|
|
|
<p class="text-xs text-muted">
|
|
@if ($autoUpdate)
|
|
{{ __('admin_settings.auto_next', [
|
|
'days' => collect($autoDays)->sort()->map(fn ($d) => __('admin_settings.weekday.'.$d))->join(', '),
|
|
'from' => $autoFrom,
|
|
'to' => $autoTo,
|
|
]) }}
|
|
@else
|
|
{{ __('admin_settings.auto_off') }}
|
|
@endif
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
@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
|
|
|
|
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
|
{{-- Website visibility and two-factor policy stack in the left
|
|
column: both are short, and the console's allow-list beside
|
|
them is long. Three cards of one row each left a column of
|
|
white space down the middle of the page. --}}
|
|
<div class="space-y-5">
|
|
{{-- 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
|
|
|
|
|
|
{{-- 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
|
|
</div>
|
|
|
|
{{-- 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
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
{{-- ── Das eigene Konto ────────────────────────────────────────────── --}}
|
|
<section class="space-y-5">
|
|
<div class="flex items-center gap-4">
|
|
<h2 class="lbl">{{ __('admin_settings.group.account') }}</h2>
|
|
<span class="h-px flex-1 bg-line"></span>
|
|
</div>
|
|
|
|
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
|
<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 gap-4">
|
|
<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>
|
|
|
|
{{-- 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">
|
|
<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>
|
|
</div>
|
|
|
|
|
|
|
|
{{-- 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>
|
|
</section>
|
|
|
|
{{-- ── Das Team ─────────────────────────────────────────────────────── --}}
|
|
@if ($canManageStaff)
|
|
<section class="space-y-5">
|
|
<div class="flex items-center gap-4">
|
|
<h2 class="lbl">{{ __('admin_settings.group.team') }}</h2>
|
|
<span class="h-px flex-1 bg-line"></span>
|
|
</div>
|
|
|
|
<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>
|
|
</section>
|
|
@endif
|
|
</div>
|