fix(authorization): gate mutable Livewire controls by policy

Disable unauthorized listbox inputs and hide application deploy actions, with updated disabled styling and coverage.
This commit is contained in:
Andras Bacsai
2026-08-18 10:18:52 +02:00
parent f588913435
commit a3e78a3677
29 changed files with 164 additions and 47 deletions
+7 -1
View File
@@ -1905,7 +1905,13 @@ html[data-theme="custom"] textarea:disabled {
.listbox-trigger:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: var(--color-neutral-100);
color: var(--color-neutral-400);
}
.dark .listbox-trigger:disabled {
background-color: color-mix(in oklab, var(--color-white) 3%, transparent);
color: var(--color-fg-faint);
}
.listbox-trigger:focus-visible {
@@ -65,7 +65,7 @@
</div>
@endif
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="enableSsl" label="SSL"
<x-forms.listbox canGate="update" :canResource="$database" id="enableSsl" label="SSL"
onChange="instantSaveSSL"
:disabled="! $isExited || ! auth()->user()?->can('update', $database)"
:options="[
@@ -73,7 +73,7 @@
['value' => false, 'label' => 'Disabled'],
]" />
@if ($sslModeOptions)
<x-forms.listbox id="sslMode" label="SSL mode" :helper="$sslModeHelper"
<x-forms.listbox canGate="update" :canResource="$database" id="sslMode" label="SSL mode" :helper="$sslModeHelper"
onChange="instantSaveSSL"
:disabled="! $enableSsl || ! $isExited || ! auth()->user()?->can('update', $database)"
:options="collect($sslModeOptions)->map(fn ($option, $value) => [
@@ -16,9 +16,16 @@
'tooltip' => true,
'portal' => false,
'preserveValue' => false,
'canGate' => null,
'canResource' => null,
'autoDisable' => true,
])
@php
if ($canGate && $canResource && $autoDisable && ! Illuminate\Support\Facades\Gate::allows($canGate, $canResource)) {
$disabled = true;
}
$triggerId = ($htmlId ?? $id).'-trigger';
$panelId = ($htmlId ?? $id).'-panel';
@endphp
@@ -15,7 +15,7 @@
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="discordPingEnabled" label="Critical event mention"
<x-forms.listbox canGate="update" :canResource="$settings" id="discordPingEnabled" label="Critical event mention"
helper="Mention @here when a critical event occurs."
onChange="instantSaveDiscordPingEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[
@@ -41,7 +41,7 @@
<div class="lg:col-span-2">
@if (isCloud())
<div class="w-full sm:w-72">
<x-forms.listbox id="useInstanceEmailSettings" label="Email service"
<x-forms.listbox canGate="update" :canResource="$settings" id="useInstanceEmailSettings" label="Email service"
onChange="instantSave"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Use hosted email service'],
@@ -50,7 +50,7 @@
</div>
@else
<div class="w-full sm:w-72">
<x-forms.listbox id="useInstanceEmailSettings" label="Email service"
<x-forms.listbox canGate="update" :canResource="$settings" id="useInstanceEmailSettings" label="Email service"
onChange="instantSave"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Use system-wide settings'],
@@ -85,7 +85,7 @@
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-3">
<div class="w-full sm:w-72">
<x-forms.listbox id="smtpEnabled" label="SMTP delivery"
<x-forms.listbox canGate="update" :canResource="$settings" id="smtpEnabled" label="SMTP delivery"
onChange="submitSmtp"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
@@ -97,7 +97,7 @@
placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpPort"
type="number" placeholder="587" label="Port" />
<x-forms.listbox id="smtpEncryption" label="Encryption" required
<x-forms.listbox canGate="update" :canResource="$settings" id="smtpEncryption" label="Encryption" required
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => 'starttls', 'label' => 'StartTLS'],
['value' => 'tls', 'label' => 'TLS / SSL'],
@@ -120,7 +120,7 @@
<div class="application-settings-form">
<x-application.settings-section title="Resend">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="resendEnabled" label="Resend delivery"
<x-forms.listbox canGate="update" :canResource="$settings" id="resendEnabled" label="Resend delivery"
onChange="submitResend"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
@@ -71,7 +71,7 @@
@if ($hasHttpsDomains && ! $labelsAreWritable)
<div class="mt-4 max-w-md">
<x-forms.listbox id="isForceHttpsEnabled" label="Redirect HTTP to HTTPS"
<x-forms.listbox canGate="update" :canResource="$application" id="isForceHttpsEnabled" label="Redirect HTTP to HTTPS"
onChange="updateForceHttps"
helper="Disable only when Cloudflare Tunnel or another proxy connects to Coolify over HTTP. Keep enabled when Cloudflare uses Full or Full (Strict) SSL."
:options="[
@@ -119,7 +119,7 @@
</x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
@if ($isCompose && count($composeServices) > 0)
<x-forms.listbox label="Service" id="newDomainService" required
<x-forms.listbox canGate="update" :canResource="$application" label="Service" id="newDomainService" required
:options="collect($composeServices)->map(fn ($serviceName) => [
'value' => $serviceName,
'label' => $serviceName,
@@ -39,6 +39,7 @@
<div class="w-full xl:hidden">
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
@can('deploy', $application)
<div id="application-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
@@ -149,6 +150,7 @@
@endif
</div>
</div>
@endcan
@endif
<div class="hidden" aria-hidden="true">
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
@@ -185,6 +187,7 @@
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
@can('deploy', $application)
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
@@ -279,6 +282,7 @@
@endif
</div>
</div>
@endcan
@endif
</div>
</div>
@@ -14,7 +14,7 @@
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<x-forms.input id="swarmReplicas" label="Replicas" required canGate="update"
:canResource="$application" />
<x-forms.listbox id="isSwarmOnlyWorkerNodes" label="Node placement" live onChange="instantSave"
<x-forms.listbox canGate="update" :canResource="$application" id="isSwarmOnlyWorkerNodes" label="Node placement" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $application)" :options="[
['value' => true, 'label' => 'Worker nodes only'],
['value' => false, 'label' => 'Manager and worker nodes'],
@@ -79,7 +79,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -94,7 +94,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -80,7 +80,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -95,7 +95,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -69,6 +69,7 @@
<div class="w-full xl:hidden">
@if ($database->destination->server->isFunctional())
@can('manage', $database)
<div id="database-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
@@ -127,6 +128,7 @@
@endif
</div>
</div>
@endcan
@endif
</div>
@@ -137,6 +139,7 @@
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($database->destination->server->isFunctional())
@can('manage', $database)
<div id="database-desktop-actions" class="flex items-center gap-0.5">
@if (! $databaseStatus->startsWith('exited'))
<button type="button" class="button button-highlighted"
@@ -156,6 +159,7 @@
</x-forms.button>
@endif
</div>
@endcan
@else
<x-status-badge status="Server unavailable" type="error" />
@endif
@@ -81,7 +81,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -104,7 +104,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -86,7 +86,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -107,7 +107,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -83,7 +83,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -104,7 +104,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -86,7 +86,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -107,7 +107,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -90,7 +90,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" :onChangeArgs="[]"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -111,7 +111,7 @@
<x-application.settings-section id="log-delivery-section" title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -85,7 +85,7 @@
<div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
:disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
@@ -110,7 +110,7 @@
<x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -93,7 +93,7 @@
</x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
{{-- Always show which service receives the domain --}}
<x-forms.listbox label="Service application" id="newServiceApplicationId" required
<x-forms.listbox canGate="update" :canResource="$service" label="Service application" id="newServiceApplicationId" required
helper="Domain will be assigned to this compose service application."
:options="collect($serviceApps)->map(fn ($app) => [
'value' => $app['id'],
@@ -170,7 +170,7 @@
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
@if ($hasHttpsDomains)
<div class="w-full sm:w-72">
<x-forms.listbox id="forceHttpsRedirects.{{ $appId }}"
<x-forms.listbox canGate="update" :canResource="$service" id="forceHttpsRedirects.{{ $appId }}"
htmlId="service-force-https-{{ $appId }}"
label="Redirect HTTP to HTTPS" onChange="updateForceHttps"
:onChangeArgs="[(int) $appId]"
@@ -73,6 +73,7 @@
<div class="w-full xl:hidden">
@if ($service->isDeployable)
@can('deploy', $service)
<div id="service-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
@@ -165,6 +166,7 @@
@endif
</div>
</div>
@endcan
@else
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
class="mb-3 inline-flex" aria-label="Open required environment variables">
@@ -183,6 +185,7 @@
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
@can('deploy', $service)
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
@@ -247,6 +250,7 @@
@endif
</div>
</div>
@endcan
@else
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
aria-label="Open required environment variables">
@@ -52,7 +52,7 @@
<x-application.settings-section title="Network"
description="Control whether this Compose stack joins Coolify's predefined network.">
<x-forms.listbox id="connectToDockerNetwork" label="Network attachment" live onChange="instantSave"
<x-forms.listbox canGate="update" :canResource="$service" id="connectToDockerNetwork" label="Network attachment" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $service)" :options="[
['value' => false, 'label' => 'Use the stack network only'],
['value' => true, 'label' => 'Connect to the predefined Coolify network'],
@@ -27,21 +27,21 @@
</x-slot:actions>
<div class="grid gap-4 md:grid-cols-2">
<x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="Name" id="name"
<x-forms.input canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)" placeholder="Name" id="name"
label="Name" required />
<x-forms.input :disabled="!auth()->user()->can('update', $resource)"
<x-forms.input canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)"
placeholder="0 0 * * * or daily" id="frequency" label="Schedule"
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression."
required />
<x-forms.input :disabled="!auth()->user()->can('update', $resource)" type="number"
<x-forms.input canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)" type="number"
placeholder="300" id="timeout" helper="Maximum execution time from 60 to 36,000 seconds."
label="Timeout (seconds)" required />
<x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="php"
<x-forms.input canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)" placeholder="php"
helper="Leave empty when the resource only has one container." id="container"
label="{{ $type === 'service' ? 'Service' : 'Container' }}" />
</div>
<div class="mt-4">
<x-forms.input :disabled="!auth()->user()->can('update', $resource)"
<x-forms.input canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)"
placeholder="php artisan schedule:run" id="command" label="Command" required />
</div>
</x-application.settings-section>
@@ -39,13 +39,13 @@
@endif
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageId" label="S3 storage" :required="$saveToS3"
<x-forms.listbox canGate="update" :canResource="$resource" id="s3StorageId" label="S3 storage" :required="$saveToS3"
:disabled="! auth()->user()?->can('update', $resource)"
:options="$availableS3Storages->map(fn ($s3Storage) => [
'value' => $s3Storage->id,
'label' => $s3Storage->name,
])->values()->all()" />
<x-forms.listbox id="disableLocalBackup" label="Local copy"
<x-forms.listbox canGate="update" :canResource="$resource" id="disableLocalBackup" label="Local copy"
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $resource)" :options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
@@ -29,7 +29,7 @@
<x-application.settings-section id="server-new-relic-drain-section" title="New Relic"
helper="Send logs through the New Relic Log API.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.listbox id="isLogDrainNewRelicEnabled" label="Status"
<x-forms.listbox canGate="update" :canResource="$server" id="isLogDrainNewRelicEnabled" label="Status"
onChange="instantSave" :options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Enabled'],
@@ -48,7 +48,7 @@
<x-application.settings-section id="server-axiom-drain-section" title="Axiom"
helper="Send logs to an Axiom dataset using its ingest API.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.listbox id="isLogDrainAxiomEnabled" label="Status"
<x-forms.listbox canGate="update" :canResource="$server" id="isLogDrainAxiomEnabled" label="Status"
onChange="instantSave" :options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Enabled'],
@@ -65,7 +65,7 @@
<x-application.settings-section id="server-custom-drain-section" title="Custom Fluent Bit"
helper="Provide a custom Fluent Bit output and optional parser configuration.">
<div class="mb-4 max-w-sm">
<x-forms.listbox id="isLogDrainCustomEnabled" label="Status"
<x-forms.listbox canGate="update" :canResource="$server" id="isLogDrainCustomEnabled" label="Status"
onChange="instantSave" :options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Enabled'],
@@ -23,14 +23,14 @@
</x-callout>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isSwarmManager" label="Manager role"
<x-forms.listbox canGate="update" :canResource="$server" id="isSwarmManager" label="Manager role"
helper="Managers control scheduling and cluster state." onChange="instantSave"
:options="[
['value' => false, 'label' => 'Not a Swarm manager'],
['value' => true, 'label' => 'Swarm manager'],
]"
:disabled="$server->settings->is_swarm_worker || !auth()->user()->can('update', $server)" />
<x-forms.listbox id="isSwarmWorker" label="Worker role"
<x-forms.listbox canGate="update" :canResource="$server" id="isSwarmWorker" label="Worker role"
helper="Workers run tasks assigned by a Swarm manager." onChange="instantSave"
:options="[
['value' => false, 'label' => 'Not a Swarm worker'],
@@ -127,7 +127,7 @@
@if (!isCloud())
<div class="lg:col-span-2">
<x-forms.listbox id="isSystemWide" label="Availability" :options="[
<x-forms.listbox canGate="update" :canResource="$github_app" id="isSystemWide" label="Availability" :options="[
['value' => false, 'label' => 'Only this team'],
['value' => true, 'label' => 'Every team on this instance'],
]"
@@ -161,7 +161,7 @@
label="Client secret" type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="webhookSecret"
label="Webhook secret" type="password" required />
<x-forms.listbox id="privateKeyId" label="Private key" required
<x-forms.listbox canGate="update" :canResource="$github_app" id="privateKeyId" label="Private key" required
:options="$privateKeyOptions" :disabled="!auth()->user()->can('update', $github_app)" />
</div>
</x-application.settings-section>
@@ -82,7 +82,7 @@
<x-forms.input canGate="update" :canResource="$gitlab_app" type="number" id="customPort"
label="SSH port" />
<div class="lg:col-span-2">
<x-forms.listbox id="privateKeyId" label="SSH private key (optional)"
<x-forms.listbox canGate="update" :canResource="$gitlab_app" id="privateKeyId" label="SSH private key (optional)"
:options="collect($privateKeys)->map(fn ($key) => [
'value' => $key->id,
'label' => $key->name,
@@ -18,7 +18,7 @@
<x-forms.input id="name" label="Name" required canGate="update" :canResource="$team" />
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
<div class="lg:col-span-2">
<x-forms.listbox id="is_mcp_server_enabled" label="MCP server"
<x-forms.listbox canGate="update" :canResource="$team" id="is_mcp_server_enabled" label="MCP server"
helper="Controls whether this team's API tokens can use the instance MCP endpoint."
:disabled="! auth()->user()->can('update', $team)" :options="[
['value' => false, 'label' => 'Disabled for this team'],
@@ -22,6 +22,15 @@ test('listbox trigger height matches shared inputs', function () {
->toMatch('/\.application-settings-workspace \.listbox-trigger[^}]*height: 2rem;/s');
});
test('disabled listboxes use the same colors as disabled inputs', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toMatch('/\.listbox-trigger:disabled \{[^}]*background-color: var\(--color-neutral-100\);[^}]*color: var\(--color-neutral-400\);/s')
->toMatch('/\.dark \.listbox-trigger:disabled \{[^}]*background-color: color-mix\(in oklab, var\(--color-white\) 3%, transparent\);[^}]*color: var\(--color-fg-faint\);/s')
->not->toMatch('/\.listbox-trigger:disabled \{[^}]*opacity:/s');
});
test('listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="longOption" label="Example"
@@ -1,9 +1,58 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
it('auto-disables listboxes when the gate denies access', function () {
Gate::define('update-listbox-test', fn (): bool => false);
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="status" :wire="false" value="enabled"
canGate="update-listbox-test" :canResource="new stdClass"
:options="[['value' => 'enabled', 'label' => 'Enabled']]" />
BLADE);
expect($html)->toMatch('/<button[^>]*id="status-trigger"[^>]*\sdisabled(?:[=\s>])/');
});
it('declares gate attributes on form controls with update permission checks', function () {
$controlPattern = '/<x-forms\.(?:listbox|input|select|checkbox|textarea|button|toggle)\b.*?(?:\/>|<\/x-forms\.[^>]+>)/s';
foreach (File::allFiles(resource_path('views')) as $file) {
$path = $file->getPathname();
$source = file_get_contents($path);
preg_match_all($controlPattern, $source, $controls);
foreach ($controls[0] as $control) {
if (! preg_match('/can\(\s*[\'\"]update[\'\"]/', $control)) {
continue;
}
expect($control, $path)->toContain('canGate="update"')
->toContain(':canResource=');
}
}
});
it('hides resource action menus when the user cannot manage the resource', function (string $path, string $ability, string $resource, string $prefix) {
$source = file_get_contents(resource_path($path));
foreach (['mobile', 'desktop'] as $viewport) {
expect($source)->toMatch(
"/@can\\('{$ability}', \\$".$resource."\\)[\\s\\S]*?<div id=\"{$prefix}-{$viewport}-actions\"/"
);
}
})->with([
'application actions' => ['views/livewire/project/application/heading.blade.php', 'deploy', 'application', 'application'],
'service actions' => ['views/livewire/project/service/heading.blade.php', 'deploy', 'service', 'service'],
'database actions' => ['views/livewire/project/database/heading.blade.php', 'manage', 'database', 'database'],
'server actions' => ['views/livewire/server/navbar.blade.php', 'manageProxy', 'server', 'server'],
]);
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
$source = file_get_contents(base_path($path));
@@ -69,4 +118,38 @@ it('authorizes every volume backup form control', function (string $path, array
'/wire:click="checkAllDns"/s',
],
],
'postgres public access control' => [
'resources/views/livewire/project/database/postgresql/general.blade.php',
[
'/<x-forms\.listbox id="isPublic"[\s\S]*?:disabled="! auth\(\)->user\(\)->can\(\'update\', \$database\)" canGate="update" :canResource="\$database" :options=/',
],
],
'redis public access control' => [
'resources/views/livewire/project/database/redis/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
'mongodb public access control' => [
'resources/views/livewire/project/database/mongodb/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
'clickhouse public access control' => [
'resources/views/livewire/project/database/clickhouse/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
'mariadb public access control' => [
'resources/views/livewire/project/database/mariadb/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
'dragonfly public access control' => [
'resources/views/livewire/project/database/dragonfly/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
'mysql public access control' => [
'resources/views/livewire/project/database/mysql/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
'keydb public access control' => [
'resources/views/livewire/project/database/keydb/general.blade.php',
['/<x-forms\.listbox id="isPublic"[\s\S]*?canGate="update" :canResource="\$database" :options=/'],
],
]);