diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php new file mode 100644 index 000000000..2cf01400e --- /dev/null +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -0,0 +1,20 @@ +contents(auth()->user()); + abort_if($contents === null, 404); + + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=300', + ]); + } +} diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 970593663..99c2567f2 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -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; @@ -32,6 +36,35 @@ class Index extends Component public bool $show_verification = false; + public $avatar; + + public function uploadAvatar(AvatarStorageService $avatarStorage): void + { + try { + $this->validate([ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=6000,max_height=6000'], + ]); + + $avatarStorage->store(Auth::user(), $this->avatar); + $this->reset('avatar'); + $this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); + $this->dispatch('success', 'Profile picture updated.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function removeAvatar(AvatarStorageService $avatarStorage): void + { + try { + $avatarStorage->delete(Auth::user()); + $this->dispatch('avatar-updated', url: null); + $this->dispatch('success', 'Profile picture removed.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function mount() { $this->userId = Auth::id(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 4c4907200..89130799a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -512,6 +512,11 @@ class All extends Component ->where('resourceable_id', $this->resource->id) ->where('is_preview', $isPreview); + $hardcodedKeys = $this->hardcodedEnvironmentVariableKeys(); + if ($hardcodedKeys !== []) { + $query->whereNotIn('key', $hardcodedKeys); + } + if ($this->serviceFilters !== []) { $query->whereRaw('1 = 0'); } @@ -716,18 +721,6 @@ class All extends Component return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']); }); - // Filter out variables that exist in database (user has overridden/managed them) - // For preview, check against preview variables; for production, check against production variables - if ($isPreview) { - $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray(); - } else { - $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray(); - } - - $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) { - return ! in_array($var['key'], $managedKeys); - }); - if ($this->searchTerm() !== '') { $hardcodedVars = $hardcodedVars->filter(function ($var) { return str($var['key'])->contains($this->searchTerm(), true); @@ -749,6 +742,27 @@ class All extends Component return $hardcodedVars; } + /** @return list */ + private function hardcodedEnvironmentVariableKeys(): array + { + if (! $this->showsHardcodedEnvironmentVariables()) { + return []; + } + + $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + + if (blank($dockerComposeRaw)) { + return []; + } + + return extractHardcodedEnvironmentVariables($dockerComposeRaw) + ->pluck('key') + ->reject(fn (string $key): bool => str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_'])) + ->unique() + ->values() + ->all(); + } + public function getDevView() { $this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false)); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 754d563a8..fd5ee616d 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -3,6 +3,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; +use App\Models\S3Storage; use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -50,6 +51,10 @@ class Advanced extends Component public ?string $domain_connect_private_key = null; + public string $avatar_storage = 'local'; + + public array $avatar_storage_options = []; + public function rules() { return [ @@ -89,6 +94,21 @@ class Advanced extends Component $this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false; // Do not prefill the secret into the form; only update when the admin pastes a new value. $this->domain_connect_private_key = null; + $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id + ? 's3:'.$this->settings->avatar_s3_storage_id + : 'local'; + $this->avatar_storage_options = [ + ['value' => 'local', 'label' => 'Local storage'], + ...S3Storage::query() + ->whereTeamId(0) + ->where('is_usable', true) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (S3Storage $storage): array => [ + 'value' => 's3:'.$storage->id, + 'label' => $storage->name.' (S3)', + ])->all(), + ]; } public function submit() @@ -190,6 +210,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { @@ -197,6 +218,29 @@ class Advanced extends Component } } + private function saveAvatarStorageSetting(): void + { + if ($this->avatar_storage === 'local') { + $this->settings->avatar_storage_type = 'local'; + $this->settings->avatar_s3_storage_id = null; + + return; + } + + $storageId = (int) str($this->avatar_storage)->after('s3:')->value(); + $storage = S3Storage::query() + ->whereTeamId(0) + ->where('is_usable', true) + ->find($storageId); + + if (! $storage || $this->avatar_storage !== 's3:'.$storage->id) { + throw new \InvalidArgumentException('The selected avatar storage is not available.'); + } + + $this->settings->avatar_storage_type = 's3'; + $this->settings->avatar_s3_storage_id = $storage->id; + } + public function clearDomainConnectPrivateKey(): void { try { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 20e3484db..877fc5b12 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -49,6 +49,8 @@ class InstanceSettings extends Model 'is_mcp_server_enabled', 'webhook_allowed_internal_hosts', 'webhook_allow_localhost', + 'avatar_storage_type', + 'avatar_s3_storage_id', ]; protected $hidden = [ diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 518159168..e8e1788e3 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -7,6 +7,7 @@ use App\Rules\ValidS3BucketName; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; @@ -184,19 +185,7 @@ class S3Storage extends BaseModel throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket')); } - $disk = Storage::build([ - 'driver' => 's3', - 'region' => $this['region'], - 'key' => $this['key'], - 'secret' => $this['secret'], - 'bucket' => $this['bucket'], - 'endpoint' => $this['endpoint'], - 'use_path_style_endpoint' => true, - 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [ - 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, - 'timeout' => self::REQUEST_TIMEOUT_SECONDS, - ]), - ]); + $disk = $this->filesystem(); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); @@ -235,6 +224,23 @@ class S3Storage extends BaseModel } } + public function filesystem(): FilesystemAdapter + { + return Storage::build([ + 'driver' => 's3', + 'region' => $this['region'], + 'key' => $this['key'], + 'secret' => $this['secret'], + 'bucket' => $this['bucket'], + 'endpoint' => $this['endpoint'], + 'use_path_style_endpoint' => true, + 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [ + 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ]), + ]); + } + /** * The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination. * diff --git a/app/Models/User.php b/app/Models/User.php index b59b553d9..5b3847396 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -53,6 +53,9 @@ class User extends Authenticatable implements SendsEmail 'pending_email', 'email_change_code', 'email_change_code_expires_at', + 'avatar_path', + 'avatar_storage_type', + 'avatar_s3_storage_id', ]; protected $hidden = [ diff --git a/app/Services/AvatarStorageService.php b/app/Services/AvatarStorageService.php new file mode 100644 index 000000000..3266d0719 --- /dev/null +++ b/app/Services/AvatarStorageService.php @@ -0,0 +1,147 @@ +avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local'; + $s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null; + $disk = $this->disk($storageType, $s3StorageId); + $path = "avatars/{$user->id}/avatar.jpg"; + $contents = $this->compress($upload); + + if (! $disk->put($path, $contents)) { + throw new RuntimeException('Unable to store the profile picture.'); + } + + $oldStorageType = $user->avatar_storage_type; + $oldS3StorageId = $user->avatar_s3_storage_id; + $oldPath = $user->avatar_path; + + $user->update([ + 'avatar_path' => $path, + 'avatar_storage_type' => $storageType, + 'avatar_s3_storage_id' => $s3StorageId, + ]); + + if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) { + $this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath); + } + } + + public function contents(User $user): ?string + { + if (! $user->avatar_path) { + return null; + } + + try { + $disk = $this->disk($user->avatar_storage_type ?? 'local', $user->avatar_s3_storage_id); + } catch (RuntimeException) { + return null; + } + + return $disk->exists($user->avatar_path) ? $disk->get($user->avatar_path) : null; + } + + public function delete(User $user): void + { + if ($user->avatar_path) { + $this->disk($user->avatar_storage_type ?? 'local', $user->avatar_s3_storage_id) + ->delete($user->avatar_path); + } + + $user->update([ + 'avatar_path' => null, + 'avatar_storage_type' => null, + 'avatar_s3_storage_id' => null, + ]); + } + + private function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter + { + if ($storageType !== 's3') { + return Storage::disk('local'); + } + + $storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first(); + if (! $storage) { + throw new RuntimeException('The configured S3 storage is not available.'); + } + + return $storage->filesystem(); + } + + private function compress(UploadedFile $upload): string + { + $imageInfo = getimagesize($upload->getRealPath()); + if ($imageInfo && $imageInfo['mime'] === 'image/jpeg' && $imageInfo[0] <= 256 && $imageInfo[1] <= 256) { + return file_get_contents($upload->getRealPath()); + } + + if (extension_loaded('imagick')) { + $image = new \Imagick($upload->getRealPath()); + $image->setIteratorIndex(0); + $image->autoOrient(); + $image->cropThumbnailImage(256, 256); + $image->stripImage(); + $image->setImageBackgroundColor('white'); + $image = $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); + $image->setImageFormat('jpeg'); + $image->setImageCompressionQuality(80); + $contents = $image->getImagesBlob(); + $image->clear(); + + return $contents; + } + + if (! function_exists('imagecreatefromstring') || ! function_exists('imagejpeg')) { + throw new RuntimeException('ImageMagick or GD is required to process profile pictures that were not compressed by the browser.'); + } + + $source = imagecreatefromstring(file_get_contents($upload->getRealPath())); + if (! $source) { + throw new RuntimeException('Unable to read the uploaded profile picture.'); + } + + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + $cropSize = min($sourceWidth, $sourceHeight); + $target = imagecreatetruecolor(256, 256); + imagefill($target, 0, 0, imagecolorallocate($target, 255, 255, 255)); + imagecopyresampled( + $target, + $source, + 0, + 0, + (int) (($sourceWidth - $cropSize) / 2), + (int) (($sourceHeight - $cropSize) / 2), + 256, + 256, + $cropSize, + $cropSize, + ); + + ob_start(); + imagejpeg($target, null, 80); + $contents = ob_get_clean(); + imagedestroy($source); + imagedestroy($target); + + if (! is_string($contents)) { + throw new RuntimeException('Unable to compress the profile picture.'); + } + + return $contents; + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 52783ff4d..b688ffbb8 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -555,12 +555,12 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $to_non_www_name = "{$loop}-{$uuid}-to-non-www"; $redirect_to_non_www = [ "traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)", - "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\${1}://\${2}", + "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false", ]; $redirect_to_www = [ "traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)", - "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\${1}://www.\${2}", + "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}", "traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false", ]; if ($schema === 'https') { diff --git a/database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php b/database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php new file mode 100644 index 000000000..c4ab23282 --- /dev/null +++ b/database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php @@ -0,0 +1,31 @@ +string('avatar_path')->nullable(); + $table->string('avatar_storage_type')->nullable(); + $table->foreignId('avatar_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('avatar_s3_storage_id'); + $table->dropColumn(['avatar_path', 'avatar_storage_type']); + }); + } +}; diff --git a/database/migrations/2026_08_07_185536_add_avatar_storage_settings_to_instance_settings_table.php b/database/migrations/2026_08_07_185536_add_avatar_storage_settings_to_instance_settings_table.php new file mode 100644 index 000000000..501a1e4a9 --- /dev/null +++ b/database/migrations/2026_08_07_185536_add_avatar_storage_settings_to_instance_settings_table.php @@ -0,0 +1,30 @@ +string('avatar_storage_type')->default('local'); + $table->foreignId('avatar_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropConstrainedForeignId('avatar_s3_storage_id'); + $table->dropColumn('avatar_storage_type'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 4e9a12d1f..372e0000c 100644 --- a/openapi.json +++ b/openapi.json @@ -366,6 +366,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" @@ -935,6 +945,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" @@ -1504,6 +1524,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" @@ -3122,6 +3152,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index dd98f718d..4ff4b12a8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -260,7 +260,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' @@ -638,7 +638,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' @@ -1016,7 +1016,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' @@ -2100,7 +2100,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' diff --git a/resources/css/app.css b/resources/css/app.css index 98e7d3899..6fb1b35cb 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1207,7 +1207,8 @@ html[data-theme="custom"] textarea:disabled { color: var(--color-accent); } -.error-extra details ul { +.error-extra details ul, +.error-proxy-help ul { margin: 0.5rem 0 0; padding-left: 1.125rem; display: flex; @@ -1215,7 +1216,8 @@ html[data-theme="custom"] textarea:disabled { gap: 0.375rem; } -.error-extra details code { +.error-extra details code, +.error-proxy-help code { font-family: var(--font-mono); font-size: 0.75rem; padding: 0.05rem 0.3rem; @@ -2232,6 +2234,7 @@ input[type="search"]::-webkit-search-results-decoration { .backup-table-grid { grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem; + min-width: 50rem; } /* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */ @@ -2466,16 +2469,6 @@ input[type="search"]::-webkit-search-results-decoration { } @media (max-width: 900px) { - .backup-table-grid { - grid-template-columns: minmax(10rem, 1.5fr) 6rem 7.5rem 6.5rem; - } - - .backup-table-grid > :nth-child(3), - .backup-table-grid > :nth-child(6), - .backup-table-grid > :nth-child(7) { - display: none; - } - .team-members-table-grid { grid-template-columns: minmax(0, 1fr) 7rem 7rem; } @@ -2527,14 +2520,6 @@ input[type="search"]::-webkit-search-results-decoration { width: 100%; } - .backup-table-grid { - grid-template-columns: minmax(0, 1fr) 7.5rem 6.5rem; - } - - .backup-table-grid > :nth-child(2) { - display: none; - } - .team-members-table-grid { grid-template-columns: minmax(0, 1fr) 6.5rem; } diff --git a/resources/views/components/error-page.blade.php b/resources/views/components/error-page.blade.php index 7a29eb0c3..11ca60cc8 100644 --- a/resources/views/components/error-page.blade.php +++ b/resources/views/components/error-page.blade.php @@ -55,10 +55,11 @@ - Contact support - + + Contact support + + @endif diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index 58d1fe4a4..d661825f4 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -13,10 +13,16 @@ appearanceOpen: false, theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), themeColor: localStorage.getItem('themeColor') || '#6b16ed', - setTheme(type) { + avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null), + setTheme(type, closeMenu = true) { this.theme = type; localStorage.setItem('theme', type); + if (closeMenu) { + this.appearanceOpen = false; + this.open = false; + } + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const isDark = type === 'dark' || type === 'custom' || (type === 'system' && prefersDark); document.documentElement.classList.toggle('dark', isDark); @@ -27,9 +33,9 @@ }, setThemeColor() { localStorage.setItem('themeColor', this.themeColor); - this.setTheme('custom'); + this.setTheme('custom', false); }, -}" @keydown.escape.window="open = false; appearanceOpen = false" +}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false" @click.outside="open = false; appearanceOpen = false"> -
! $sidebar, - 'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar, - ])> +
diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index c04efcf59..585e887a5 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -11,14 +11,14 @@ :show-dashboard="false" primary-href="/login" primary-label="Back to login"> -
- Using a reverse proxy or Cloudflare Tunnel? +
  • Set your domain in Settings → FQDN to match the URL you use to access Coolify.
  • Cloudflare users: disable Browser Integrity Check and Under Attack Mode for your Coolify domain, as these can interrupt login sessions.
  • If you can still access Coolify via localhost, log in there first to configure your FQDN.
-
+ + @livewireScripts @endsection diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index a5aec250e..090be9fd9 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -8,6 +8,102 @@ @close-email-change-modal.window="emailModalOpen = false"> Profile | Coolify
+
+
+
+

Profile picture

+

Upload a JPG, PNG, or WebP image.

+
+
+
+
+ Profile picture preview + @if (auth()->user()->avatar_path) + {{ auth()->user()->name }} + @else + + {{ strtoupper(mb_substr(auth()->user()->name ?: auth()->user()->email, 0, 1)) }} + + @endif +
+
+ +

+ @error('avatar') +

{{ $message }}

+ @enderror +
+ + Upload picture + Compressing… + + @if (auth()->user()->avatar_path) + Remove + @endif +
+
+
+
+
diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index 12660d527..888083210 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -172,7 +172,7 @@
@if ($backups->isNotEmpty()) -
+
Target Type diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 6445a155c..b3775a1dd 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -7,8 +7,6 @@ }"> - {{-- Temporarily hidden: the "Compose parser" dev hint and the "View details" - resource-details modal trigger. --}}
@if ($buildPack === 'dockercompose') diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index c524da243..be825792f 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -180,8 +180,6 @@
- {{-- Status badge temporarily hidden — will be redesigned later: - --}} @if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)) Load a Compose file to deploy. @else diff --git a/resources/views/livewire/project/service/edit-compose.blade.php b/resources/views/livewire/project/service/edit-compose.blade.php index 3acbfcc2a..78400cb76 100644 --- a/resources/views/livewire/project/service/edit-compose.blade.php +++ b/resources/views/livewire/project/service/edit-compose.blade.php @@ -1,6 +1,6 @@
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index 65eb0e3fb..89dfa0e5c 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -12,7 +12,8 @@ -
@@ -20,7 +21,11 @@ @if (blank($service->service_type)) - Validate + + + Validate + @endif diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 78c7d969b..6b8373a63 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -173,7 +173,7 @@
@if ($backups->isNotEmpty()) -
+
Target Type diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index c40733512..e8fdbdab3 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -101,12 +101,13 @@ @endif - + @if ($execution->status === 'success' && ! $execution->local_storage_deleted) - - Download - + @endif @if ($execution->status !== 'running') - Delete + @endif diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index ae70c8536..607c5c4a2 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -134,6 +134,19 @@ ]" />
+ + +
+ +
+ @if (count($avatar_storage_options) === 1) + + Add and test an S3-compatible storage under Storages before selecting it here. + + @endif +
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 6939049f9..4512f48a3 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -98,12 +98,11 @@ this.targetOpen = false; this.targetSearch = ''; await $wire.set('selected_uuid', target.value); - await $wire.connectToContainer(); } }"> @if ($selected_uuid === 'default')
@@ -111,8 +110,8 @@ :theme-accents="$consoleThemeAccents" />
-
+ class="terminal-target-picker z-10 flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]"> +

Start a terminal session

{{ $isLoadingContainers ? 'Finding available servers and containers…' : 'Choose a server or container. The terminal will open after you select a target.' }} @@ -126,7 +125,7 @@

@endif
-
+
@if ($isLoadingContainers)
diff --git a/routes/web.php b/routes/web.php index 49a87790c..40869b4b9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\OauthController; +use App\Http\Controllers\ProfileAvatarController; use App\Http\Controllers\UploadController; use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Boarding\Index as BoardingIndex; @@ -159,6 +160,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); + Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); Route::prefix('tags')->group(function () { diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index a9cdd35cc..3ed26d4af 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2783,7 +2783,7 @@ "category": "devtools", "logo": "svgs/librespeed.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-25T23:48:15+00:00", + "template_last_updated_at": "2026-02-25T23:48:15Z", "port": "82" }, "libretranslate": { @@ -2920,7 +2920,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T13:19:47+00:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", diff --git a/templates/service-templates.json b/templates/service-templates.json index 5e647dbf3..c1944c165 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2783,7 +2783,7 @@ "category": "devtools", "logo": "svgs/librespeed.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-25T23:48:15+00:00", + "template_last_updated_at": "2026-02-25T23:48:15Z", "port": "82" }, "libretranslate": { @@ -2920,7 +2920,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T13:19:47+00:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index eca364238..539dba5f2 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -301,7 +301,7 @@ YAML, $parsedCompose = applicationParser($application); $labels = collect(data_get($parsedCompose, 'services.frontend.labels')); - expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=${1}://www.${2}')))->toBeTrue(); + expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=$${1}://www.$${2}')))->toBeTrue(); }); test('compose domain reconciliation preserves stored domains when parsing returns no services', function () { diff --git a/tests/Feature/ComposeEditorLayoutTest.php b/tests/Feature/ComposeEditorLayoutTest.php index 953bcc0e0..d8538e82d 100644 --- a/tests/Feature/ComposeEditorLayoutTest.php +++ b/tests/Feature/ComposeEditorLayoutTest.php @@ -13,6 +13,10 @@ it('uses the large modal treatment for the compose editor', function () { ->toContain("\$dispatch('compose-preview-toggle')") ->toContain("\$dispatch('compose-save')") ->toContain('@compose-save-finished.window="saving = false"') + ->toContain('@compose-validate-finished.window="validating = false"') + ->toContain('@click="validating = true; $dispatch(\'compose-validate\')"') + ->toContain('x-bind:disabled="validating"') + ->toContain('') ->toContain('') ->toContain('x-bind:disabled="saving"') ->not->toContain('name="refresh"') @@ -36,6 +40,7 @@ it('renders the compose editor with clear guidance settings and actions', functi ->toContain('min-h-[24rem]') ->toContain('@compose-preview-toggle.window') ->toContain('@compose-save.window') + ->toContain('@compose-validate.window="$wire.validateCompose().finally(() => $dispatch(\'compose-validate-finished\'))"') ->not->toContain("finally(() => \$dispatch('compose-save-finished'))") ->not->toContain('sticky bottom-0') ->not->toContain('Cancel') diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php index cc114ae61..a44124ea7 100644 --- a/tests/Feature/EnvironmentVariableSearchTest.php +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -182,6 +182,34 @@ YAML, ->toBe(['API_TOKEN']); }); +it('shows a Compose-defined value as read-only when a managed variable has the same key', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + - API_TOKEN=from-compose +YAML, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'from-environment-tab', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables'); + + expect($component->instance()->environmentVariablePageRows) + ->toHaveCount(1) + ->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('hardcoded') + ->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']['value'])->toBe('from-compose'); +}); + it('searches service environment variables without requiring preview variables', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, diff --git a/tests/Feature/ErrorPagesRedesignTest.php b/tests/Feature/ErrorPagesRedesignTest.php index a54a7e3a1..8d1c7ae32 100644 --- a/tests/Feature/ErrorPagesRedesignTest.php +++ b/tests/Feature/ErrorPagesRedesignTest.php @@ -57,9 +57,22 @@ it('uses login as primary action on session expired page', function () { ->toContain('/login') ->toContain('Back to login') ->toContain('Using a reverse proxy or Cloudflare Tunnel?') + ->toContain('x-data="{ open: false }"') + ->toContain('x-on:click="open = !open"') + ->toContain('livewire.js') ->not->toContain('>Dashboard $exception])->render(); + + expect($html) + ->toContain('href="'.config('constants.urls.contact').'"') + ->toMatch('/]*>\s*Contact support/s'); +}); + it('shows purified exception message on 500 page', function () { $exception = new RuntimeException('Database connection failed'); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index cf1ed1287..4019606e1 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -13,6 +13,26 @@ it('keeps the volume backup executions table horizontally scrollable on mobile', ->not->toContain('.data-table-header.volume-backup-executions-grid'); }); +it('uses compact icon actions for volume backup executions', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')); + + expect($view) + ->toContain('title="Download backup" aria-label="Download backup"') + ->toContain('') + ->toContain('title="Delete backup" aria-label="Delete backup"') + ->toContain(''); +}); + +it('keeps storage backup schedule tables horizontally scrollable on mobile', function () { + $applicationView = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php')); + $serviceView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php')); + $css = file_get_contents(resource_path('css/app.css')); + + expect($applicationView)->toContain('class="data-table w-full overflow-x-auto"') + ->and($serviceView)->toContain('class="data-table w-full overflow-x-auto"') + ->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/'); +}); + use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php new file mode 100644 index 000000000..04c158a34 --- /dev/null +++ b/tests/Feature/ProfileAvatarTest.php @@ -0,0 +1,131 @@ + InstanceSettings::create([ + 'id' => 0, + 'avatar_storage_type' => 'local', + ])); +}); + +it('compresses and stores an uploaded profile picture on the configured local storage', function () { + Storage::fake('local'); + $user = User::factory()->create(['name' => 'Test User']); + $this->actingAs($user); + + Livewire::test(Index::class) + ->set('avatar', UploadedFile::fake()->image('profile.png', 1200, 900)) + ->call('uploadAvatar') + ->assertHasNoErrors(); + + $user->refresh(); + + expect($user->avatar_path) + ->toBe("avatars/{$user->id}/avatar.jpg") + ->and($user->avatar_storage_type)->toBe('local') + ->and($user->avatar_s3_storage_id)->toBeNull(); + + Storage::disk('local')->assertExists($user->avatar_path); + + $image = getimagesizefromstring(Storage::disk('local')->get($user->avatar_path)); + + expect($image[0])->toBeLessThanOrEqual(256) + ->and($image[1])->toBeLessThanOrEqual(256) + ->and($image['mime'])->toBe('image/jpeg') + ->and(Storage::disk('local')->size($user->avatar_path))->toBeLessThan(100_000); +}); + +it('stores an already compressed browser JPEG without server image extensions', function () { + Storage::fake('local'); + $user = User::factory()->create(['name' => 'Test User']); + $contents = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gODAK/9sAQwAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCko/9sAQwEHBwcKCAoTCgoTKBoWGigoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo/8AAEQgAAgACAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+qaKKKAP/9k='); + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, $contents); + $upload = new UploadedFile($path, 'avatar.jpg', 'image/jpeg', null, true); + + app(AvatarStorageService::class)->store($user, $upload); + + expect(Storage::disk('local')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents); +}); + +it('serves the authenticated users profile picture', function () { + Storage::fake('local'); + $user = User::factory()->create([ + 'name' => 'Test User', + 'avatar_path' => 'avatars/1/avatar.jpg', + 'avatar_storage_type' => 'local', + ]); + Storage::disk('local')->put($user->avatar_path, 'avatar-content'); + + $this->withoutMiddleware()->actingAs($user) + ->get(route('profile.avatar')) + ->assertSuccessful() + ->assertHeader('content-type', 'image/jpeg'); +}); + +it('removes the current profile picture', function () { + Storage::fake('local'); + $user = User::factory()->create([ + 'name' => 'Test User', + 'avatar_path' => 'avatars/1/avatar.jpg', + 'avatar_storage_type' => 'local', + ]); + Storage::disk('local')->put($user->avatar_path, 'avatar-content'); + $this->actingAs($user); + + Livewire::test(Index::class) + ->call('removeAvatar') + ->assertHasNoErrors(); + + expect($user->refresh()->avatar_path)->toBeNull(); + Storage::disk('local')->assertMissing('avatars/1/avatar.jpg'); +}); + +it('falls back cleanly when the avatars S3 storage no longer exists', function () { + $user = User::factory()->create([ + 'name' => 'Test User', + 'avatar_path' => 'avatars/1/avatar.webp', + 'avatar_storage_type' => 's3', + 'avatar_s3_storage_id' => null, + ]); + + $this->withoutMiddleware()->actingAs($user) + ->get(route('profile.avatar')) + ->assertNotFound(); +}); + +it('renders the profile upload and user menu avatar', function () { + $profile = file_get_contents(resource_path('views/livewire/profile/index.blade.php')); + $menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php')); + + expect($profile) + ->toContain("this.\$wire.upload('avatar', compressed") + ->toContain('canvas.toBlob') + ->toContain('wire:click="uploadAvatar"') + ->and($menu) + ->toContain("route('profile.avatar',"); +}); + +it('offers runtime local or existing S3 profile picture storage', function () { + $component = file_get_contents(app_path('Livewire/Settings/Advanced.php')); + $view = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php')); + + expect($component) + ->toContain("['value' => 'local', 'label' => 'Local storage']") + ->toContain("'value' => 's3:'.\$storage->id") + ->toContain('->whereTeamId(0)') + ->toContain("->where('is_usable', true)") + ->and($view) + ->toContain('id="avatar_storage"') + ->toContain('Use S3 for multi-instance or cloud deployments'); +}); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 2295531cc..1de4296aa 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -144,6 +144,17 @@ it('shows connection progress in the terminal body instead of the header', funct ->toContain("this.starting = this.\$el.dataset.autoStart === 'true';"); }); +it('starts the global terminal only once when a target is selected', function () { + $view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php')); + $component = file_get_contents(app_path('Livewire/Terminal/Index.php')); + + expect($view) + ->toContain("await \$wire.set('selected_uuid', target.value);") + ->not->toContain('await $wire.connectToContainer();') + ->and($component) + ->toMatch('/public function updatedSelectedUuid\(\).*?\$this->connectToContainer\(\);/s'); +}); + it('uses the redesigned terminal canvas and controls on resource terminal pages', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php')); diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php index 7a545f92c..e19cfef92 100644 --- a/tests/Feature/SentinelUnsavedBarFlashTest.php +++ b/tests/Feature/SentinelUnsavedBarFlashTest.php @@ -108,7 +108,7 @@ test('settings advanced unsaved bar scopes dirty tracking away from instantSave expect($contents) ->toContain('x-unsaved-bar') - ->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost"') + ->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key"') ->toContain('onChange="instantSave"') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php index e9fdb88a3..d4bfd925a 100644 --- a/tests/Feature/TerminalPageHeaderTest.php +++ b/tests/Feature/TerminalPageHeaderTest.php @@ -23,6 +23,15 @@ it('shows a centered themed target canvas before loading xterm', function () { ->toContain("@else\n
toContain('data-terminal-target-picker="page"') + ->toContain('flex max-h-full w-full max-w-lg flex-col') + ->toContain('class="terminal-target-list min-h-0 flex-1 overflow-y-auto p-2"'); +}); + it('loads targets inside the themed session picker with an accent scrollbar', function () { $view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php')); $styles = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/TopUserMenuTest.php b/tests/Feature/TopUserMenuTest.php index 16488f58d..04aef2415 100644 --- a/tests/Feature/TopUserMenuTest.php +++ b/tests/Feature/TopUserMenuTest.php @@ -25,8 +25,15 @@ it('changes appearance from a submenu instead of navigating to a separate page', expect($menu) ->toContain('appearanceOpen: false') ->toContain('@click.outside="open = false; appearanceOpen = false"') - ->toContain("theme: localStorage.getItem('theme') || 'dark'") - ->toContain('setTheme(type)') + ->toContain("theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark')") + ->toContain('setTheme(type, closeMenu = true)') + ->toContain("this.setTheme('custom', false)") + ->not->toContain('@change="appearanceOpen = false; open = false"') + ->toContain('this.appearanceOpen = false;') + ->toContain('this.open = false;') + ->toContain('