diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index a26e7daaa..b5ebd92b2 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -30,7 +30,6 @@ public function handle($manual_update = false) if (! $this->server) { return; } - CleanupDocker::dispatch($this->server, false, false); // Fetch fresh version from CDN instead of using cache try { @@ -117,17 +116,12 @@ public function handle($manual_update = false) private function update() { - $helperImage = config('constants.coolify.helper_image'); - $latest_version = getHelperVersion(); - instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false); - - $image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion; - instant_remote_process(["docker pull -q $image"], $this->server, false); - + $latestHelperImageVersion = getHelperVersion(); $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); + remote_process([ "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", - "bash /data/coolify/source/upgrade.sh $this->latestVersion", + "bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion", ], $this->server); } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b2c211fa8..c8402cbf4 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -309,7 +309,9 @@ public function normal(Request $request) if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); } - $applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false); + $applications = Application::where('repository_project_id', $id) + ->where('source_id', $github_app->id) + ->whereRelation('source', 'is_public', false); if ($x_github_event === 'push') { $applications = $applications->where('git_branch', $branch)->get(); if ($applications->isEmpty()) { diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index e97cceb0d..9508c2adc 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,10 +2,8 @@ namespace App\Livewire; -use App\Models\InstanceSettings; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class NavbarDeleteTeam extends Component @@ -19,12 +17,8 @@ public function mount() public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $currentTeam = currentTeam(); diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index 44ab419c2..8c0ee1a3f 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -20,6 +20,8 @@ class Show extends Component public bool $is_debug_enabled = false; + public bool $fullscreen = false; + private bool $deploymentFinishedDispatched = false; public function getListeners() diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 18ad93016..d70c52411 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,12 +2,9 @@ namespace App\Livewire\Project\Database; -use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -154,12 +151,8 @@ public function delete($password) { $this->authorize('manageBackups', $this->backup->database); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 0b6d8338b..44f903fcc 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -2,11 +2,9 @@ namespace App\Livewire\Project\Database; -use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class BackupExecutions extends Component @@ -69,12 +67,8 @@ public function cleanupDeleted() public function deleteBackup($executionId, $password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $execution = $this->backup->executions()->where('id', $executionId)->first(); diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index 4bcf866d3..1e183c6bc 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -4,12 +4,9 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Models\InstanceSettings; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class Database extends Component @@ -96,12 +93,8 @@ public function delete($password) try { $this->authorize('delete', $this->database); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $this->database->delete(); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2ce4374a0..079115bb6 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -3,7 +3,6 @@ namespace App\Livewire\Project\Service; use App\Models\Application; -use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -16,8 +15,6 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -62,7 +59,7 @@ public function mount() $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); $this->syncData(); } @@ -104,7 +101,8 @@ public function convertToDirectory() public function loadStorageOnServer() { try { - $this->authorize('update', $this->resource); + // Loading content is a read operation, so we use 'view' permission + $this->authorize('view', $this->resource); $this->fileStorage->loadStorageOnServer(); $this->syncData(); @@ -140,12 +138,8 @@ public function delete($password) { $this->authorize('update', $this->resource); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } try { diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 68544f1ab..4302c05fb 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -2,12 +2,9 @@ namespace App\Livewire\Project\Service; -use App\Models\InstanceSettings; use App\Models\ServiceApplication; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; use Spatie\Url\Url; @@ -128,12 +125,8 @@ public function delete($password) try { $this->authorize('delete', $this->application); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $this->application->delete(); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 644b100b8..12d8bcbc3 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -67,7 +67,7 @@ public function refreshStoragesFromEvent() public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->resource->refresh(); + $this->resource->load('persistentStorages.resource'); } public function getFilesProperty() diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 0ed1347f8..8bf3c7438 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -3,13 +3,10 @@ namespace App\Livewire\Project\Shared; use App\Jobs\DeleteResourceJob; -use App\Models\InstanceSettings; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -93,12 +90,8 @@ public function mount() public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } if (! $this->resource) { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 28e3f23e7..ffd18b35c 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -5,12 +5,9 @@ use App\Actions\Application\StopApplicationOneServer; use App\Actions\Docker\GetContainersStatus; use App\Events\ApplicationStatusChanged; -use App\Models\InstanceSettings; use App\Models\Server; use App\Models\StandaloneDocker; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -140,12 +137,8 @@ public function addServer(int $network_id, int $server_id) public function removeServer(int $network_id, int $server_id, $password) { try { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 5970ec904..2091eca14 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -2,11 +2,8 @@ namespace App\Livewire\Project\Shared\Storages; -use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class Show extends Component @@ -67,7 +64,7 @@ private function syncData(bool $toModel = false): void public function mount() { $this->syncData(false); - $this->isReadOnly = $this->storage->isReadOnlyVolume(); + $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } public function submit() @@ -84,12 +81,8 @@ public function delete($password) { $this->authorize('update', $this->resource); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $this->storage->delete(); diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 8c2c54c99..27a6e7aca 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,11 +3,8 @@ namespace App\Livewire\Server; use App\Actions\Server\DeleteServer; -use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class Delete extends Component @@ -29,12 +26,8 @@ public function mount(string $server_uuid) public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } try { $this->authorize('delete', $this->server); diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index 284eea7dd..310edcfe4 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -2,11 +2,8 @@ namespace App\Livewire\Server\Security; -use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -44,13 +41,9 @@ public function toggleTerminal($password) throw new \Exception('Only team administrators and owners can modify terminal access.'); } - // Verify password unless two-step confirmation is disabled - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + // Verify password + if (! verifyPasswordConfirmation($password, $this)) { + return; } // Toggle the terminal setting diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 4626a9135..7a4a1c480 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -5,9 +5,12 @@ use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Services\HetznerService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -73,6 +76,17 @@ class Show extends Component public bool $isValidating = false; + // Hetzner linking properties + public Collection $availableHetznerTokens; + + public ?int $selectedHetznerTokenId = null; + + public ?array $matchedHetznerServer = null; + + public ?string $hetznerSearchError = null; + + public bool $hetznerNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -150,6 +164,9 @@ public function mount(string $server_uuid) $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->isValidating = $this->server->is_validating ?? false; + // Load Hetzner tokens for linking + $this->loadHetznerTokens(); + } catch (\Throwable $e) { return handleError($e, $this); } @@ -465,6 +482,98 @@ public function submit() } } + public function loadHetznerTokens(): void + { + $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function searchHetznerServer(): void + { + $this->hetznerSearchError = null; + $this->hetznerNoMatchFound = false; + $this->matchedHetznerServer = null; + + if (! $this->selectedHetznerTokenId) { + $this->hetznerSearchError = 'Please select a Hetzner token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId); + if (! $token) { + $this->hetznerSearchError = 'Invalid token selected.'; + + return; + } + + $hetznerService = new HetznerService($token->token); + $matched = $hetznerService->findServerByIp($this->server->ip); + + if ($matched) { + $this->matchedHetznerServer = $matched; + } else { + $this->hetznerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage(); + } + } + + public function linkToHetzner() + { + if (! $this->matchedHetznerServer) { + $this->dispatch('error', 'No Hetzner server selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + // Verify the server exists and is accessible with the token + $hetznerService = new HetznerService($token->token); + $serverData = $hetznerService->getServer($this->matchedHetznerServer['id']); + + if (empty($serverData)) { + $this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']); + + return; + } + + // Update the server with Hetzner details + $this->server->update([ + 'cloud_provider_token_id' => $this->selectedHetznerTokenId, + 'hetzner_server_id' => $this->matchedHetznerServer['id'], + 'hetzner_server_status' => $serverData['status'] ?? null, + ]); + + $this->hetznerServerStatus = $serverData['status'] ?? null; + + // Clear the linking state + $this->matchedHetznerServer = null; + $this->selectedHetznerTokenId = null; + $this->hetznerNoMatchFound = false; + $this->hetznerSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.server.show'); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index be38ae1d8..b011d2dc1 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -5,8 +5,6 @@ use App\Models\InstanceSettings; use App\Models\Server; use App\Rules\ValidIpOrCidr; -use Auth; -use Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -157,9 +155,7 @@ public function instantSave() public function toggleTwoStepConfirmation($password): bool { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - + if (! verifyPasswordConfirmation($password, $this)) { return false; } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 6d6915ae2..c8d44d42b 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -2,10 +2,7 @@ namespace App\Livewire\Team; -use App\Models\InstanceSettings; use App\Models\User; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class AdminView extends Component @@ -58,12 +55,8 @@ public function delete($id, $password) return redirect()->route('dashboard'); } - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } if (! auth()->user()->isInstanceAdmin()) { diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index f13baa7a7..36bee2a23 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -4,6 +4,7 @@ use App\Actions\Server\UpdateCoolify; use App\Models\InstanceSettings; +use App\Models\Server; use Livewire\Component; class Upgrade extends Component @@ -14,12 +15,20 @@ class Upgrade extends Component public string $latestVersion = ''; + public string $currentVersion = ''; + protected $listeners = ['updateAvailable' => 'checkUpdate']; + public function mount() + { + $this->currentVersion = config('constants.coolify.version'); + } + public function checkUpdate() { try { $this->latestVersion = get_latest_version_of_coolify(); + $this->currentVersion = config('constants.coolify.version'); $this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false); if (isDev()) { $this->isUpgradeAvailable = true; @@ -41,4 +50,71 @@ public function upgrade() return handleError($e, $this); } } + + public function getUpgradeStatus(): array + { + // Only root team members can view upgrade status + if (auth()->user()?->currentTeam()?->id !== 0) { + return ['status' => 'none']; + } + + $server = Server::find(0); + if (! $server) { + return ['status' => 'none']; + } + + $statusFile = '/data/coolify/source/.upgrade-status'; + + try { + $content = instant_remote_process( + ["cat {$statusFile} 2>/dev/null || echo ''"], + $server, + false + ); + $content = trim($content ?? ''); + } catch (\Throwable $e) { + return ['status' => 'none']; + } + + if (empty($content)) { + return ['status' => 'none']; + } + + $parts = explode('|', $content); + if (count($parts) < 3) { + return ['status' => 'none']; + } + + [$step, $message, $timestamp] = $parts; + + // Check if status is stale (older than 10 minutes) + try { + $statusTime = new \DateTime($timestamp); + $now = new \DateTime; + $diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60; + + if ($diffMinutes > 10) { + return ['status' => 'none']; + } + } catch (\Throwable $e) { + return ['status' => 'none']; + } + + if ($step === 'error') { + return [ + 'status' => 'error', + 'step' => 0, + 'message' => $message, + ]; + } + + $stepInt = (int) $step; + $status = $stepInt >= 6 ? 'complete' : 'in_progress'; + + return [ + 'status' => $status, + 'step' => $stepInt, + 'message' => $message, + ]; + } } diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 96170dbd6..9d7095cb5 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -209,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path) return $query->get()->where('plain_mount_path', $path); } + // Check if this volume belongs to a service resource + public function isServiceResource(): bool + { + return in_array($this->resource_type, [ + 'App\Models\ServiceApplication', + 'App\Models\ServiceDatabase', + ]); + } + + // Determine if this volume should be read-only in the UI + // File/directory mounts can be edited even for services + public function shouldBeReadOnlyInUI(): bool + { + // Check for explicit :ro flag in compose (existing logic) + return $this->isReadOnlyVolume(); + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { @@ -239,22 +256,40 @@ public function isReadOnlyVolume(): bool $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match + // Note: We match on mount_path (container path) only, since fs_path gets transformed + // from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing foreach ($volumes as $volume) { // Volume can be string like "host:container:ro" or "host:container" if (is_string($volume)) { $parts = explode(':', $volume); - // Check if this volume matches our fs_path and mount_path + // Check if this volume matches our mount_path if (count($parts) >= 2) { - $hostPath = $parts[0]; $containerPath = $parts[1]; $options = $parts[2] ?? null; - // Match based on fs_path and mount_path - if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) { + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { return $options === 'ro'; } } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $readOnly === true; + } } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index e7862478b..7126253ea 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model { protected $guarded = []; + public function resource() + { + return $this->morphTo('resource'); + } + public function application() { return $this->morphTo('resource'); @@ -50,6 +55,54 @@ protected function hostPath(): Attribute ); } + // Check if this volume belongs to a service resource + public function isServiceResource(): bool + { + return in_array($this->resource_type, [ + 'App\Models\ServiceApplication', + 'App\Models\ServiceDatabase', + ]); + } + + // Check if this volume belongs to a dockercompose application + public function isDockerComposeResource(): bool + { + if ($this->resource_type !== 'App\Models\Application') { + return false; + } + + // Only access relationship if already eager loaded to avoid N+1 + if (! $this->relationLoaded('resource')) { + return false; + } + + $application = $this->resource; + if (! $application) { + return false; + } + + return data_get($application, 'build_pack') === 'dockercompose'; + } + + // Determine if this volume should be read-only in the UI + // Service volumes and dockercompose application volumes are read-only + // (users should edit compose file directly) + public function shouldBeReadOnlyInUI(): bool + { + // All service volumes should be read-only in UI + if ($this->isServiceResource()) { + return true; + } + + // All dockercompose application volumes should be read-only in UI + if ($this->isDockerComposeResource()) { + return true; + } + + // Check for explicit :ro flag in compose (existing logic) + return $this->isReadOnlyVolume(); + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { @@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match + // Note: We match on mount_path (container path) only, since host paths get transformed foreach ($volumes as $volume) { // Volume can be string like "host:container:ro" or "host:container" if (is_string($volume)) { @@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool return $options === 'ro'; } } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $readOnly === true; + } } } diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 47652eb35..3aae55966 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -20,6 +20,28 @@ class S3Storage extends BaseModel 'secret' => 'encrypted', ]; + /** + * Boot the model and register event listeners. + */ + protected static function boot(): void + { + parent::boot(); + + // Trim whitespace from credentials before saving to prevent + // "Malformed Access Key Id" errors from accidental whitespace in pasted values. + // Note: We use the saving event instead of Attribute mutators because key/secret + // use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which + // would cause issues with the encryption/decryption cycle. + static::saving(function (S3Storage $storage) { + if ($storage->key !== null) { + $storage->key = trim($storage->key); + } + if ($storage->secret !== null) { + $storage->secret = trim($storage->secret); + } + }); + } + public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); @@ -55,6 +77,36 @@ protected function path(): Attribute ); } + /** + * Trim whitespace from endpoint to prevent malformed URLs. + */ + protected function endpoint(): Attribute + { + return Attribute::make( + set: fn (?string $value) => $value ? trim($value) : null, + ); + } + + /** + * Trim whitespace from bucket name to prevent connection errors. + */ + protected function bucket(): Attribute + { + return Attribute::make( + set: fn (?string $value) => $value ? trim($value) : null, + ); + } + + /** + * Trim whitespace from region to prevent connection errors. + */ + protected function region(): Attribute + { + return Attribute::make( + set: fn (?string $value) => $value ? trim($value) : null, + ); + } + public function testConnection(bool $shouldSave = false) { try { diff --git a/app/Models/User.php b/app/Models/User.php index f04b6fa77..b790efcf1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -443,4 +443,13 @@ public function hasEmailChangeRequest(): bool && $this->email_change_code_expires_at && Carbon::now()->lessThan($this->email_change_code_expires_at); } + + /** + * Check if the user has a password set. + * OAuth users are created without passwords. + */ + public function hasPassword(): bool + { + return ! empty($this->password); + } } diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 234bc37ad..abd115550 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -43,21 +43,26 @@ public function send(SendsEmail $notifiable, Notification $notification): void throw new Exception('No email recipients found'); } - foreach ($recipients as $recipient) { - // Check if the recipient is part of the team - if (! $members->contains('email', $recipient)) { - $emailSettings = $notifiable->emailNotificationSettings; - data_set($emailSettings, 'smtp_password', '********'); - data_set($emailSettings, 'resend_api_key', '********'); - send_internal_notification(sprintf( - "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s", - $recipient, - $team, - get_class($notification), - get_class($notifiable), - json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - )); - throw new Exception('Recipient is not part of the team'); + // Skip team membership validation for test notifications + $isTestNotification = data_get($notification, 'isTestNotification', false); + + if (! $isTestNotification) { + foreach ($recipients as $recipient) { + // Check if the recipient is part of the team + if (! $members->contains('email', $recipient)) { + $emailSettings = $notifiable->emailNotificationSettings; + data_set($emailSettings, 'smtp_password', '********'); + data_set($emailSettings, 'resend_api_key', '********'); + send_internal_notification(sprintf( + "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s", + $recipient, + $team, + get_class($notification), + get_class($notifiable), + json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + )); + throw new Exception('Recipient is not part of the team'); + } } } diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index 60bc8a0ee..bbed22777 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -23,6 +23,8 @@ class Test extends Notification implements ShouldQueue public $tries = 5; + public bool $isTestNotification = true; + public function __construct(public ?string $emails = null, public ?string $channel = null, public ?bool $ping = false) { $this->onQueue('high'); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index 3add70db2..2f7d70bbf 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -8,6 +8,8 @@ class Test extends CustomEmailNotification { + public bool $isTestNotification = true; + public function __construct(public string $emails, public bool $isTransactionalEmail = true) { $this->onQueue('high'); diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index f7855090a..1de7eb2b1 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -161,4 +161,30 @@ public function deleteServer(int $serverId): void { $this->request('delete', "/servers/{$serverId}"); } + + public function getServers(): array + { + return $this->requestPaginated('get', '/servers', 'servers'); + } + + public function findServerByIp(string $ip): ?array + { + $servers = $this->getServers(); + + foreach ($servers as $server) { + // Check IPv4 + $ipv4 = data_get($server, 'public_net.ipv4.ip'); + if ($ipv4 === $ip) { + return $server; + } + + // Check IPv6 (Hetzner returns the full /64 block) + $ipv6 = data_get($server, 'public_net.ipv6.ip'); + if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) { + return $server; + } + } + + return null; + } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 1066f1a63..3d9e9e729 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -33,6 +33,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\RateLimiter; @@ -3308,3 +3309,57 @@ function formatContainerStatus(string $status): string return str($status)->headline()->value(); } } + +/** + * Check if password confirmation should be skipped. + * Returns true if: + * - Two-step confirmation is globally disabled + * - User has no password (OAuth users) + * + * Used by modal-confirmation.blade.php to determine if password step should be shown. + * + * @return bool True if password confirmation should be skipped + */ +function shouldSkipPasswordConfirmation(): bool +{ + // Skip if two-step confirmation is globally disabled + if (data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + return true; + } + + // Skip if user has no password (OAuth users) + if (! Auth::user()?->hasPassword()) { + return true; + } + + return false; +} + +/** + * Verify password for two-step confirmation. + * Skips verification if: + * - Two-step confirmation is globally disabled + * - User has no password (OAuth users) + * + * @param mixed $password The password to verify (may be array if skipped by frontend) + * @param \Livewire\Component|null $component Optional Livewire component to add errors to + * @return bool True if verification passed (or skipped), false if password is incorrect + */ +function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool +{ + // Skip if password confirmation should be skipped + if (shouldSkipPasswordConfirmation()) { + return true; + } + + // Verify the password + if (! Hash::check($password, Auth::user()->password)) { + if ($component) { + $component->addError('password', 'The provided password is incorrect.'); + } + + return false; + } + + return true; +} diff --git a/config/constants.php b/config/constants.php index 15ec73625..d9734c48e 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.454', + 'version' => '4.0.0-beta.455', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php index 2c92b0e19..a9c59cbc3 100644 --- a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php +++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php @@ -11,16 +11,18 @@ */ public function up(): void { - Schema::create('cloud_provider_tokens', function (Blueprint $table) { - $table->id(); - $table->foreignId('team_id')->constrained()->onDelete('cascade'); - $table->string('provider'); - $table->text('token'); - $table->string('name')->nullable(); - $table->timestamps(); + if (! Schema::hasTable('cloud_provider_tokens')) { + Schema::create('cloud_provider_tokens', function (Blueprint $table) { + $table->id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('provider'); + $table->text('token'); + $table->string('name')->nullable(); + $table->timestamps(); - $table->index(['team_id', 'provider']); - }); + $table->index(['team_id', 'provider']); + }); + } } /** diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php index b1c9ec48b..b5cae7d32 100644 --- a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php +++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->bigInteger('hetzner_server_id')->nullable()->after('id'); - }); + if (! Schema::hasColumn('servers', 'hetzner_server_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->bigInteger('hetzner_server_id')->nullable()->after('id'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('hetzner_server_id'); - }); + if (Schema::hasColumn('servers', 'hetzner_server_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_id'); + }); + } } }; diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php index a25a4ce83..9f23a7ee9 100644 --- a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php +++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); - }); + if (! Schema::hasColumn('servers', 'cloud_provider_token_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); + }); + } } /** @@ -21,9 +23,11 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropForeign(['cloud_provider_token_id']); - $table->dropColumn('cloud_provider_token_id'); - }); + if (Schema::hasColumn('servers', 'cloud_provider_token_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['cloud_provider_token_id']); + $table->dropColumn('cloud_provider_token_id'); + }); + } } }; diff --git a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php index d94c9c76f..54a0a37ba 100644 --- a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php +++ b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->string('hetzner_server_status')->nullable()->after('hetzner_server_id'); - }); + if (! Schema::hasColumn('servers', 'hetzner_server_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('hetzner_server_status')->nullable()->after('hetzner_server_id'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('hetzner_server_status'); - }); + if (Schema::hasColumn('servers', 'hetzner_server_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_status'); + }); + } } }; diff --git a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php index ddb655d2c..b1309713d 100644 --- a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php +++ b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->boolean('is_validating')->default(false)->after('hetzner_server_status'); - }); + if (! Schema::hasColumn('servers', 'is_validating')) { + Schema::table('servers', function (Blueprint $table) { + $table->boolean('is_validating')->default(false)->after('hetzner_server_status'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('is_validating'); - }); + if (Schema::hasColumn('servers', 'is_validating')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('is_validating'); + }); + } } }; diff --git a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php index 56ed2239a..f968d2926 100644 --- a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php +++ b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('instance_settings', function (Blueprint $table) { - $table->string('dev_helper_version')->nullable(); - }); + if (! Schema::hasColumn('instance_settings', 'dev_helper_version')) { + Schema::table('instance_settings', function (Blueprint $table) { + $table->string('dev_helper_version')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('dev_helper_version'); - }); + if (Schema::hasColumn('instance_settings', 'dev_helper_version')) { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('dev_helper_version'); + }); + } } }; diff --git a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php index 067861e16..59223a506 100644 --- a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php +++ b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('scheduled_tasks', function (Blueprint $table) { - $table->integer('timeout')->default(300)->after('frequency'); - }); + if (! Schema::hasColumn('scheduled_tasks', 'timeout')) { + Schema::table('scheduled_tasks', function (Blueprint $table) { + $table->integer('timeout')->default(300)->after('frequency'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('scheduled_tasks', function (Blueprint $table) { - $table->dropColumn('timeout'); - }); + if (Schema::hasColumn('scheduled_tasks', 'timeout')) { + Schema::table('scheduled_tasks', function (Blueprint $table) { + $table->dropColumn('timeout'); + }); + } } }; diff --git a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php index 14fdd5998..ff45b1fcf 100644 --- a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php +++ b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php @@ -11,12 +11,29 @@ */ public function up(): void { - Schema::table('scheduled_task_executions', function (Blueprint $table) { - $table->timestamp('started_at')->nullable()->after('scheduled_task_id'); - $table->integer('retry_count')->default(0)->after('status'); - $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds'); - $table->text('error_details')->nullable()->after('message'); - }); + if (! Schema::hasColumn('scheduled_task_executions', 'started_at')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->timestamp('started_at')->nullable()->after('scheduled_task_id'); + }); + } + + if (! Schema::hasColumn('scheduled_task_executions', 'retry_count')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->integer('retry_count')->default(0)->after('status'); + }); + } + + if (! Schema::hasColumn('scheduled_task_executions', 'duration')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds'); + }); + } + + if (! Schema::hasColumn('scheduled_task_executions', 'error_details')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->text('error_details')->nullable()->after('message'); + }); + } } /** @@ -24,8 +41,13 @@ public function up(): void */ public function down(): void { - Schema::table('scheduled_task_executions', function (Blueprint $table) { - $table->dropColumn(['started_at', 'retry_count', 'duration', 'error_details']); - }); + $columns = ['started_at', 'retry_count', 'duration', 'error_details']; + foreach ($columns as $column) { + if (Schema::hasColumn('scheduled_task_executions', $column)) { + Schema::table('scheduled_task_executions', function (Blueprint $table) use ($column) { + $table->dropColumn($column); + }); + } + } } }; diff --git a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php index 329ac7af9..b9dfd4d9d 100644 --- a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php +++ b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php @@ -11,11 +11,23 @@ */ public function up(): void { - Schema::table('applications', function (Blueprint $table) { - $table->integer('restart_count')->default(0)->after('status'); - $table->timestamp('last_restart_at')->nullable()->after('restart_count'); - $table->string('last_restart_type', 10)->nullable()->after('last_restart_at'); - }); + if (! Schema::hasColumn('applications', 'restart_count')) { + Schema::table('applications', function (Blueprint $table) { + $table->integer('restart_count')->default(0)->after('status'); + }); + } + + if (! Schema::hasColumn('applications', 'last_restart_at')) { + Schema::table('applications', function (Blueprint $table) { + $table->timestamp('last_restart_at')->nullable()->after('restart_count'); + }); + } + + if (! Schema::hasColumn('applications', 'last_restart_type')) { + Schema::table('applications', function (Blueprint $table) { + $table->string('last_restart_type', 10)->nullable()->after('last_restart_at'); + }); + } } /** @@ -23,8 +35,13 @@ public function up(): void */ public function down(): void { - Schema::table('applications', function (Blueprint $table) { - $table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']); - }); + $columns = ['restart_count', 'last_restart_at', 'last_restart_type']; + foreach ($columns as $column) { + if (Schema::hasColumn('applications', $column)) { + Schema::table('applications', function (Blueprint $table) use ($column) { + $table->dropColumn($column); + }); + } + } } }; diff --git a/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php b/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php index 3bab33368..290423526 100644 --- a/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php +++ b/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->string('detected_traefik_version')->nullable(); - }); + if (! Schema::hasColumn('servers', 'detected_traefik_version')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('detected_traefik_version')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('detected_traefik_version'); - }); + if (Schema::hasColumn('servers', 'detected_traefik_version')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('detected_traefik_version'); + }); + } } }; diff --git a/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php b/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php index ac509dc71..61a9c80b1 100644 --- a/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php +++ b/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('email_notification_settings', function (Blueprint $table) { - $table->boolean('traefik_outdated_email_notifications')->default(true); - }); + if (! Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->boolean('traefik_outdated_email_notifications')->default(true); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('email_notification_settings', function (Blueprint $table) { - $table->dropColumn('traefik_outdated_email_notifications'); - }); + if (Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('traefik_outdated_email_notifications'); + }); + } } }; diff --git a/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php b/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php index b7d69e634..3ceb07da8 100644 --- a/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php +++ b/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('telegram_notification_settings', function (Blueprint $table) { - $table->text('telegram_notifications_traefik_outdated_thread_id')->nullable(); - }); + if (! Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->text('telegram_notifications_traefik_outdated_thread_id')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('telegram_notification_settings', function (Blueprint $table) { - $table->dropColumn('telegram_notifications_traefik_outdated_thread_id'); - }); + if (Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->dropColumn('telegram_notifications_traefik_outdated_thread_id'); + }); + } } }; diff --git a/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php b/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php index 99e10707d..12fca4190 100644 --- a/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php +++ b/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->json('traefik_outdated_info')->nullable(); - }); + if (! Schema::hasColumn('servers', 'traefik_outdated_info')) { + Schema::table('servers', function (Blueprint $table) { + $table->json('traefik_outdated_info')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('traefik_outdated_info'); - }); + if (Schema::hasColumn('servers', 'traefik_outdated_info')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('traefik_outdated_info'); + }); + } } }; diff --git a/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php b/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php index 5f41816f6..f38c9c2a8 100644 --- a/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php +++ b/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php @@ -11,10 +11,17 @@ */ public function up(): void { - Schema::table('application_settings', function (Blueprint $table) { - $table->boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets'); - $table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile'); - }); + if (! Schema::hasColumn('application_settings', 'inject_build_args_to_dockerfile')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets'); + }); + } + + if (! Schema::hasColumn('application_settings', 'include_source_commit_in_build')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile'); + }); + } } /** @@ -22,9 +29,16 @@ public function up(): void */ public function down(): void { - Schema::table('application_settings', function (Blueprint $table) { - $table->dropColumn('inject_build_args_to_dockerfile'); - $table->dropColumn('include_source_commit_in_build'); - }); + if (Schema::hasColumn('application_settings', 'inject_build_args_to_dockerfile')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('inject_build_args_to_dockerfile'); + }); + } + + if (Schema::hasColumn('application_settings', 'include_source_commit_in_build')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('include_source_commit_in_build'); + }); + } } }; diff --git a/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php index a1bcab5bb..88c236239 100644 --- a/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php +++ b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('server_settings', function (Blueprint $table) { - $table->integer('deployment_queue_limit')->default(25)->after('concurrent_builds'); - }); + if (! Schema::hasColumn('server_settings', 'deployment_queue_limit')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->integer('deployment_queue_limit')->default(25)->after('concurrent_builds'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('server_settings', function (Blueprint $table) { - $table->dropColumn('deployment_queue_limit'); - }); + if (Schema::hasColumn('server_settings', 'deployment_queue_limit')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('deployment_queue_limit'); + }); + } } }; diff --git a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php index 97547ac45..3cc027466 100644 --- a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php +++ b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php @@ -8,15 +8,19 @@ { public function up(): void { - Schema::table('application_settings', function (Blueprint $table) { - $table->integer('docker_images_to_keep')->default(2); - }); + if (! Schema::hasColumn('application_settings', 'docker_images_to_keep')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->integer('docker_images_to_keep')->default(2); + }); + } } public function down(): void { - Schema::table('application_settings', function (Blueprint $table) { - $table->dropColumn('docker_images_to_keep'); - }); + if (Schema::hasColumn('application_settings', 'docker_images_to_keep')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('docker_images_to_keep'); + }); + } } }; diff --git a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php index a2433e5c9..dc70cc9f0 100644 --- a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php +++ b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php @@ -8,15 +8,19 @@ { public function up(): void { - Schema::table('server_settings', function (Blueprint $table) { - $table->boolean('disable_application_image_retention')->default(false); - }); + if (! Schema::hasColumn('server_settings', 'disable_application_image_retention')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->boolean('disable_application_image_retention')->default(false); + }); + } } public function down(): void { - Schema::table('server_settings', function (Blueprint $table) { - $table->dropColumn('disable_application_image_retention'); - }); + if (Schema::hasColumn('server_settings', 'disable_application_image_retention')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('disable_application_image_retention'); + }); + } } }; diff --git a/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php index bd285c180..56f44794d 100644 --- a/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php +++ b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php @@ -13,25 +13,27 @@ */ public function up(): void { - Schema::table('cloud_provider_tokens', function (Blueprint $table) { - $table->string('uuid')->nullable()->unique()->after('id'); - }); - - // Generate UUIDs for existing records using chunked processing - DB::table('cloud_provider_tokens') - ->whereNull('uuid') - ->chunkById(500, function ($tokens) { - foreach ($tokens as $token) { - DB::table('cloud_provider_tokens') - ->where('id', $token->id) - ->update(['uuid' => (string) new Cuid2]); - } + if (! Schema::hasColumn('cloud_provider_tokens', 'uuid')) { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->string('uuid')->nullable()->unique()->after('id'); }); - // Make uuid non-nullable after filling in values - Schema::table('cloud_provider_tokens', function (Blueprint $table) { - $table->string('uuid')->nullable(false)->change(); - }); + // Generate UUIDs for existing records using chunked processing + DB::table('cloud_provider_tokens') + ->whereNull('uuid') + ->chunkById(500, function ($tokens) { + foreach ($tokens as $token) { + DB::table('cloud_provider_tokens') + ->where('id', $token->id) + ->update(['uuid' => (string) new Cuid2]); + } + }); + + // Make uuid non-nullable after filling in values + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->change(); + }); + } } /** @@ -39,8 +41,10 @@ public function up(): void */ public function down(): void { - Schema::table('cloud_provider_tokens', function (Blueprint $table) { - $table->dropColumn('uuid'); - }); + if (Schema::hasColumn('cloud_provider_tokens', 'uuid')) { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } } }; diff --git a/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php b/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php new file mode 100644 index 000000000..bb59d7358 --- /dev/null +++ b/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php @@ -0,0 +1,112 @@ +select(['id', 'key', 'secret', 'endpoint', 'bucket', 'region']) + ->orderBy('id') + ->chunk(100, function ($storages) { + foreach ($storages as $storage) { + try { + DB::transaction(function () use ($storage) { + $updates = []; + + // Trim endpoint (not encrypted) + if ($storage->endpoint !== null) { + $trimmedEndpoint = trim($storage->endpoint); + if ($trimmedEndpoint !== $storage->endpoint) { + $updates['endpoint'] = $trimmedEndpoint; + } + } + + // Trim bucket (not encrypted) + if ($storage->bucket !== null) { + $trimmedBucket = trim($storage->bucket); + if ($trimmedBucket !== $storage->bucket) { + $updates['bucket'] = $trimmedBucket; + } + } + + // Trim region (not encrypted) + if ($storage->region !== null) { + $trimmedRegion = trim($storage->region); + if ($trimmedRegion !== $storage->region) { + $updates['region'] = $trimmedRegion; + } + } + + // Trim key (encrypted) - verify re-encryption works before saving + if ($storage->key !== null) { + try { + $decryptedKey = Crypt::decryptString($storage->key); + $trimmedKey = trim($decryptedKey); + if ($trimmedKey !== $decryptedKey) { + $encryptedKey = Crypt::encryptString($trimmedKey); + // Verify the new encryption is valid + if (Crypt::decryptString($encryptedKey) === $trimmedKey) { + $updates['key'] = $encryptedKey; + } else { + Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for key, skipping"); + } + } + } catch (\Throwable $e) { + Log::warning("Could not decrypt S3 storage key for ID {$storage->id}: ".$e->getMessage()); + } + } + + // Trim secret (encrypted) - verify re-encryption works before saving + if ($storage->secret !== null) { + try { + $decryptedSecret = Crypt::decryptString($storage->secret); + $trimmedSecret = trim($decryptedSecret); + if ($trimmedSecret !== $decryptedSecret) { + $encryptedSecret = Crypt::encryptString($trimmedSecret); + // Verify the new encryption is valid + if (Crypt::decryptString($encryptedSecret) === $trimmedSecret) { + $updates['secret'] = $encryptedSecret; + } else { + Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for secret, skipping"); + } + } + } catch (\Throwable $e) { + Log::warning("Could not decrypt S3 storage secret for ID {$storage->id}: ".$e->getMessage()); + } + } + + if (! empty($updates)) { + DB::table('s3_storages')->where('id', $storage->id)->update($updates); + Log::info("Trimmed whitespace from S3 storage credentials for ID {$storage->id}", [ + 'fields_updated' => array_keys($updates), + ]); + } + }); + } catch (\Throwable $e) { + Log::error("Failed to process S3 storage ID {$storage->id}: ".$e->getMessage()); + // Continue with next record instead of failing entire migration + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Cannot reverse trimming operation + } +}; diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index bfcd11095..0d3896647 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -64,9 +64,45 @@ if [ -f /root/.docker/config.json ]; then DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json" fi -if [ -f /data/coolify/source/docker-compose.custom.yml ]; then - echo "docker-compose.custom.yml detected." >>"$LOGFILE" - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 -else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 -fi +# Pull all required images before stopping containers +# This ensures we don't take down the system if image pull fails (rate limits, network issues, etc.) +echo "Pulling required Docker images..." >>"$LOGFILE" +docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify helper image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +docker pull postgres:15-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull PostgreSQL image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +docker pull redis:7-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull Redis image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +# Pull realtime image - version is hardcoded in docker-compose.prod.yml, extract it or use a known version +docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify realtime image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +echo "All images pulled successfully." >>"$LOGFILE" + +# Stop and remove existing Coolify containers to prevent conflicts +# This handles both old installations (project "source") and new ones (project "coolify") +# Use nohup to ensure the script continues even if SSH connection is lost +echo "Starting container restart sequence (detached)..." >>"$LOGFILE" + +nohup bash -c " + LOGFILE='$LOGFILE' + DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT' + REGISTRY_URL='$REGISTRY_URL' + LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION' + LATEST_IMAGE='$LATEST_IMAGE' + + # Stop and remove containers + echo 'Stopping existing Coolify containers...' >>\"\$LOGFILE\" + for container in coolify coolify-db coolify-redis coolify-realtime; do + if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then + docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true + docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true + echo \" - Removed container: \$container\" >>\"\$LOGFILE\" + fi + done + + # Start new containers + if [ -f /data/coolify/source/docker-compose.custom.yml ]; then + echo 'docker-compose.custom.yml detected.' >>\"\$LOGFILE\" + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + else + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + fi + echo 'Upgrade completed.' >>\"\$LOGFILE\" +" >>"$LOGFILE" 2>&1 & diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 1441c7c5e..94c23ede4 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.454" + "version": "4.0.0-beta.455" }, "nightly": { - "version": "4.0.0-beta.455" + "version": "4.0.0-beta.456" }, "helper": { "version": "1.0.12" diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index edff3b6bf..73939092e 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -29,17 +29,23 @@ @php use App\Models\InstanceSettings; + // Global setting to disable ALL two-step confirmation (text + password) $disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation'); + // Skip ONLY password confirmation for OAuth users (they have no password) + $skipPasswordConfirmation = shouldSkipPasswordConfirmation(); if ($temporaryDisableTwoStepConfirmation) { $disableTwoStepConfirmation = false; + $skipPasswordConfirmation = false; } + // When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm" + $effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText; @endphp