fix: restore security and broken-feature fixes lost in the revert

Security:
- Settings email: authorize the SMTP and Resend toggles.
- Server navbar: show Sentinel status only to users who can view Sentinel.
- GitHub source: permission gates on Sync name and Delete.

Broken features:
- Profile: avatar upload (missing import and file uploads trait).
- API tokens: keep the "Never" expiry choice.
- Environment variables: show validation errors instead of a generic error.
- Servers API: read the server uuid from the route in domains_by_server,
  so ?uuid=<application> filters instead of returning 404.
- Cloud servers: Power On for stopped Hetzner, DigitalOcean and Vultr servers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-28 08:40:01 +02:00
co-authored by Claude Opus 5.5
parent 1c3de6a4cd
commit 178e6665f8
9 changed files with 68 additions and 7 deletions
@@ -305,7 +305,7 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
+4
View File
@@ -2,15 +2,19 @@
namespace App\Livewire\Profile;
use App\Services\AvatarStorageService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Index extends Component
{
use WithFileUploads;
public int $userId;
public string $email;
@@ -17,6 +17,7 @@ use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -188,7 +189,6 @@ class Show extends Component
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
@@ -330,6 +330,8 @@ class Show extends Component
if ($this->is_required && $this->resource instanceof Service) {
event(new ApplicationConfigurationChanged($this->resource->team()->id));
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
return handleError($e);
}
+8 -2
View File
@@ -15,7 +15,13 @@ class ApiTokens extends Component
public ?string $description = null;
public ?int $expiresInDays = 30;
/**
* Untyped on purpose: Livewire skips hydrating null into typed properties, which
* would silently revert a "Never" (null) choice back to the default on the next request.
*
* @var int|string|null
*/
public $expiresInDays = 30;
public $tokens = [];
@@ -143,7 +149,7 @@ class ApiTokens extends Component
throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.');
}
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null;
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt);
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
+2
View File
@@ -163,6 +163,7 @@ class SettingsEmail extends Component
public function toggleSmtp()
{
try {
$this->authorize('update', $this->settings);
$this->resetErrorBag();
if ($this->smtpEnabled) {
@@ -185,6 +186,7 @@ class SettingsEmail extends Component
public function toggleResend()
{
try {
$this->authorize('update', $this->settings);
$this->resetErrorBag();
if ($this->resendEnabled) {
@@ -85,7 +85,9 @@
$activeServerMenuItem['route'],
$serverRouteParameters,
);
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
$showSentinelStatus = $server->isFunctional()
&& $server->isSentinelEnabled()
&& auth()->user()?->can('viewSentinel', $server);
$proxyCanBeStopped = in_array($proxyStatus, ['running', 'starting', 'restarting'], true);
@endphp
@@ -88,6 +88,24 @@
<x-reicon name="refresh" class="size-3.5" />
</x-forms.button>
@endif
@if ($server->cloudProviderToken)
@if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off')
<x-forms.button type="button" wire:click.prevent="startHetznerServer" isHighlighted
canGate="update" :canResource="$server">
Power On
</x-forms.button>
@elseif ($provider === 'DigitalOcean' && $digitalOceanDropletStatus === 'off')
<x-forms.button type="button" wire:click.prevent="startDigitalOceanDroplet"
isHighlighted canGate="update" :canResource="$server">
Power On
</x-forms.button>
@elseif ($provider === 'Vultr' && $vultrInstanceStatus === 'stopped')
<x-forms.button type="button" wire:click.prevent="startVultrInstance" isHighlighted
canGate="update" :canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@endif
@if ($server->server_metadata)
<x-forms.button type="button" class="size-8! px-0!"
@@ -110,7 +110,8 @@
Test connection
</x-forms.button>
@endif
<x-forms.button type="button" wire:click.prevent="updateGithubAppName">
<x-forms.button type="button" canGate="update" :canResource="$github_app"
wire:click.prevent="updateGithubAppName">
<x-reicon name="refresh" class="size-3.5" />
Sync name
</x-forms.button>
@@ -197,7 +198,8 @@
shortConfirmationLabel="GitHub App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button isError disabled tooltip="You do not have permission to delete this GitHub App.">
<x-forms.button isError disabled canGate="delete" :canResource="$github_app"
tooltip="You do not have permission to delete this GitHub App.">
Delete
</x-forms.button>
@endcan
@@ -62,3 +62,28 @@ test('enabling Resend disables SMTP in storage', function () {
expect($this->settings->resend_enabled)->toBeTrue();
expect($this->settings->smtp_enabled)->toBeFalse();
});
test('a user who is no longer an instance admin cannot toggle email providers', function (string $action, string $column) {
$this->settings->update([
'smtp_enabled' => $column === 'smtp_enabled',
'resend_enabled' => $column === 'resend_enabled',
'resend_api_key' => 're_test_key',
'smtp_host' => 'smtp.example.com',
'smtp_port' => '587',
'smtp_from_address' => 'from@example.com',
'smtp_from_name' => 'Coolify',
]);
$component = Livewire::test(SettingsEmail::class);
$member = User::factory()->create();
$member->teams()->attach($this->rootTeam, ['role' => 'member']);
$this->actingAs($member);
$component->call($action)->assertDispatched('error');
expect($this->settings->refresh()->{$column})->toBeTrue();
})->with([
'SMTP' => ['toggleSmtp', 'smtp_enabled'],
'Resend' => ['toggleResend', 'resend_enabled'],
]);