diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index c80da0cab..104a84a1b 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -778,7 +778,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint)) + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $this->s3->trustedInternalHosts())) ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) ->implode(' '); $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index a2c2360d0..b2f35d0c8 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -307,7 +307,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint)) + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) ->implode(' '); $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 61e8bba34..a3605f28e 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -99,7 +99,8 @@ class Docker extends Component ]); } } - redirectRoute($this, 'destination.show', [$docker->uuid]); + + return redirectRoute($this, 'destination.show', [$docker->uuid]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php new file mode 100644 index 000000000..adb1234e6 --- /dev/null +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -0,0 +1,144 @@ + ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'], + 'frequency' => ['required', 'string'], + ]; + } + + public function mount(): void + { + $this->authorize('view', $this->service); + $this->targetLocked = $this->selectedTargetKey !== null; + $this->targetKey = $this->selectedTargetKey; + $this->targets = $this->availableTargets(); + $this->targetKey ??= data_get($this->targets->first(), 'key'); + $this->loadSelectedBackup(); + } + + public function updatedTargetKey(): void + { + $this->loadSelectedBackup(); + } + + public function submit(): void + { + $this->authorize('update', $this->service); + $this->validate(); + $target = $this->selectedTarget(); + + if (! $target) { + $this->addError('targetKey', 'Select a volume or directory owned by this service.'); + + return; + } + if (! validate_cron_expression($this->frequency)) { + $this->addError('frequency', 'The frequency must be a valid cron or human expression.'); + + return; + } + + try { + $backup = $target->scheduledBackups()->updateOrCreate([], [ + 'team_id' => currentTeam()->id, + 'frequency' => $this->frequency, + 'enabled' => true, + ]); + $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.'); + redirectRoute($this, 'project.service.volume-backups.show', [ + 'project_uuid' => $this->service->project()->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'backup_uuid' => $backup->uuid, + ]); + } catch (\Throwable $exception) { + handleError($exception, $this); + } + } + + public function render() + { + return view('livewire.project.application.backup.create'); + } + + private function availableTargets(): Collection + { + $resources = $this->service->applications()->get()->concat($this->service->databases()->get()); + $targets = collect(); + + foreach ($resources as $resource) { + $label = str($resource->name)->headline(); + $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ + 'key' => 'volume:'.$volume->id, + 'type' => 'Volume · '.$label, + 'name' => $volume->name, + ])); + $targets->push(...$resource->fileStorages() + ->where('is_directory', true) + ->where('is_host_file', false) + ->orderBy('fs_path') + ->get() + ->map(fn (LocalFileVolume $directory): array => [ + 'key' => 'directory:'.$directory->id, + 'type' => 'Directory · '.$label, + 'name' => $directory->fs_path, + ])); + } + + return $this->targetLocked + ? $targets->where('key', $this->selectedTargetKey)->values() + : $targets->values(); + } + + private function loadSelectedBackup(): void + { + $backup = $this->selectedTarget()?->scheduledBackups()->first(); + if ($backup) { + $this->frequency = $backup->frequency; + } + } + + private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null + { + [$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null); + if (! ctype_digit((string) $id) || ! in_array($type, ['volume', 'directory'], true)) { + return null; + } + + $target = ($type === 'volume' ? LocalPersistentVolume::query() : LocalFileVolume::query())->find((int) $id); + if (! $target || ($type === 'directory' && (! $target->is_directory || $target->is_host_file))) { + return null; + } + + return $target->resource?->service_id === $this->service->id ? $target : null; + } +} diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php new file mode 100644 index 000000000..ad6b7d6c2 --- /dev/null +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -0,0 +1,53 @@ + '$refresh']; + + public function mount(): void + { + $this->service = $this->findService(); + $this->authorize('view', $this->service); + $this->parameters = get_route_parameters(); + $this->search = request()->string('search')->toString(); + } + + public function render(): View + { + $backups = ScheduledVolumeBackup::query() + ->with(['backupable.resource', 'latestExecution']) + ->withCount('executions') + ->forService($this->service) + ->latest() + ->get(); + + return view('livewire.project.service.volume-backup.index', ['backups' => $backups]); + } + + private function findService(): Service + { + $project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail(); + $environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); + + return $environment->services() + ->with(['server.settings', 'environment.project']) + ->where('uuid', request()->route('service_uuid')) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Project/Service/VolumeBackup/Show.php b/app/Livewire/Project/Service/VolumeBackup/Show.php new file mode 100644 index 000000000..eec60497f --- /dev/null +++ b/app/Livewire/Project/Service/VolumeBackup/Show.php @@ -0,0 +1,52 @@ +projects()->where('uuid', request()->route('project_uuid'))->firstOrFail(); + $environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); + $this->service = $environment->services() + ->with(['server.settings', 'environment.project']) + ->where('uuid', request()->route('service_uuid')) + ->firstOrFail(); + $this->authorize('view', $this->service); + + $this->backup = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->where('uuid', request()->route('backup_uuid')) + ->forService($this->service) + ->firstOrFail(); + $this->parameters = get_route_parameters(); + $this->section = match (request()->route()?->getName()) { + 'project.service.volume-backups.s3' => 's3', + 'project.service.volume-backups.retention' => 'retention', + 'project.service.volume-backups.executions' => 'executions', + 'project.service.volume-backups.danger' => 'danger', + default => 'general', + }; + } + + public function render(): View + { + return view('livewire.project.service.volume-backup.show'); + } +} diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 0d80aef02..6ef0f4bf1 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -6,6 +6,8 @@ use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Models\ScheduledVolumeBackup; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -38,9 +40,6 @@ class All extends Component public bool $canUpdate = false; - /** Storage id for the single shared backup modal (null = closed / unmounted). */ - public ?int $backupModalStorageId = null; - protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; public function mount(): void @@ -48,7 +47,9 @@ class All extends Component $this->canUpdate = (bool) auth()->user()?->can('update', $this->resource); $this->supportsPreviewSuffix = $this->resource instanceof Application && $this->resource->git_based(); - $this->showActionsColumn = $this->resource instanceof Application; + $this->showActionsColumn = $this->resource instanceof Application + || $this->resource instanceof ServiceApplication + || $this->resource instanceof ServiceDatabase; $this->isComposeOrService = $this->resource->type() === 'service' || data_get($this->resource, 'build_pack') === 'dockercompose'; @@ -132,7 +133,6 @@ class All extends Component } $storage->delete(); - $this->backupModalStorageId = null; $this->refreshList(); $this->dispatch('refreshStorages'); $this->dispatch('configurationChanged'); @@ -140,17 +140,6 @@ class All extends Component return true; } - public function openBackupModal(int $storageId): void - { - $this->authorize('update', $this->resource); - $this->backupModalStorageId = $storageId; - } - - public function closeBackupModal(): void - { - $this->backupModalStorageId = null; - } - public function render() { return view('livewire.project.shared.storages.all'); @@ -186,7 +175,7 @@ class All extends Component { $this->volumeBackupMeta = []; - if (! $this->resource instanceof Application) { + if (! $this->showActionsColumn) { return; } @@ -212,7 +201,7 @@ class All extends Component ->where('is_host_file', false) ->pluck('id'); - $totalApplicationBackups = ScheduledVolumeBackup::query() + $totalResourceBackups = ScheduledVolumeBackup::query() ->where(function ($query) use ($volumeMorph, $volumeIds, $directoryMorph, $directoryIds): void { $query->where(function ($query) use ($volumeMorph, $volumeIds): void { $query->where('backupable_type', $volumeMorph) @@ -224,7 +213,12 @@ class All extends Component }) ->count(); - $parameters = [ + $service = $this->resource instanceof Application ? null : $this->resource->service; + $parameters = $service ? [ + 'project_uuid' => $service->project()->uuid, + 'environment_uuid' => $service->environment->uuid, + 'service_uuid' => $service->uuid, + ] : [ 'project_uuid' => $this->resource->project()->uuid, 'environment_uuid' => $this->resource->environment->uuid, 'application_uuid' => $this->resource->uuid, @@ -236,9 +230,10 @@ class All extends Component $url = null; if ($enabled && $backup) { - $url = $totalApplicationBackups > 1 - ? route('project.application.backup.index', [...$parameters, 'search' => $storage->name]) - : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); + $routePrefix = $service ? 'project.service.volume-backups' : 'project.application.backup'; + $url = $totalResourceBackups > 1 + ? route($routePrefix.'.index', [...$parameters, 'search' => $storage->name]) + : route($routePrefix.'.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } $this->volumeBackupMeta[(int) $storage->id] = [ diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 3e56c4086..d361f90ca 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -8,6 +8,7 @@ use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; +use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Routing\Redirector; @@ -196,12 +197,7 @@ class VolumeBackups extends Component VolumeBackupJob::dispatch($this->backup); $this->dispatch('success', 'Storage backup queued.'); - return redirect()->route('project.application.backup.executions', [ - 'project_uuid' => $this->resource->project()->uuid, - 'environment_uuid' => $this->resource->environment->uuid, - 'application_uuid' => $this->resource->uuid, - 'backup_uuid' => $this->backup->uuid, - ]); + return redirect()->route($this->routeName('executions'), $this->routeParameters()); } public function delete(?string $password = null, array $selectedActions = []): bool|string @@ -220,11 +216,7 @@ class VolumeBackups extends Component DeleteScheduledVolumeBackup::run($this->backup); $this->backup = null; $this->dispatch('success', 'Storage backup schedule and archives deleted.'); - $this->redirectRoute('project.application.backup.index', [ - 'project_uuid' => $this->resource->project()->uuid, - 'environment_uuid' => $this->resource->environment->uuid, - 'application_uuid' => $this->resource->uuid, - ], navigate: true); + $this->redirectRoute($this->routeName('index'), $this->routeParameters(includeBackup: false), navigate: true); return true; } catch (Throwable $exception) { @@ -385,4 +377,25 @@ class VolumeBackups extends Component ->where('is_usable', true) ->exists(); } + + private function routeName(string $section): string + { + return $this->resource instanceof Service + ? "project.service.volume-backups.{$section}" + : "project.application.backup.{$section}"; + } + + private function routeParameters(bool $includeBackup = true): array + { + $parameters = [ + 'project_uuid' => $this->resource->project()->uuid, + 'environment_uuid' => $this->resource->environment->uuid, + ]; + $parameters[$this->resource instanceof Service ? 'service_uuid' : 'application_uuid'] = $this->resource->uuid; + if ($includeBackup) { + $parameters['backup_uuid'] = $this->backup?->uuid; + } + + return $parameters; + } } diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index d8f3ec93e..7051f473a 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -129,12 +129,14 @@ class Form extends Component // Update component property to reflect the new validation status $this->isUsable = $this->storage->is_usable; + $this->dispatch('storage-status-changed', isUsable: $this->isUsable); return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); } catch (\Throwable $e) { // Refresh model and sync to get the latest state $this->storage->refresh(); $this->isUsable = $this->storage->is_usable; + $this->dispatch('storage-status-changed', isUsable: $this->isUsable); $this->dispatch('error', 'Failed to test connection.', $e->getMessage()); } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index 914366d4f..89782d686 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -7,6 +7,7 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\On; use Livewire\Component; class Show extends Component @@ -48,6 +49,12 @@ class Show extends Component } } + #[On('storage-status-changed')] + public function refreshStorageStatus(bool $isUsable): void + { + $this->storage->refresh(); + } + public function render() { return view('livewire.storage.show'); diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 82a358344..518159168 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -172,7 +172,7 @@ class S3Storage extends BaseModel 'bucket' => $this['bucket'], ], [ - 'endpoint' => ['required', new SafeWebhookUrl], + 'endpoint' => ['required', new SafeWebhookUrl(trustedInternalHosts: $this->trustedInternalHosts())], 'bucket' => ['required', new ValidS3BucketName], ], ); @@ -192,7 +192,7 @@ class S3Storage extends BaseModel 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], 'use_path_style_endpoint' => true, - 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [ + 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [ 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, 'timeout' => self::REQUEST_TIMEOUT_SECONDS, ]), @@ -235,6 +235,18 @@ class S3Storage extends BaseModel } } + /** + * The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination. + * + * @return array + */ + public function trustedInternalHosts(): array + { + return $this->uuid === 'minio' && parse_url($this->endpoint, PHP_URL_HOST) === 'coolify-minio' + ? ['coolify-minio'] + : []; + } + private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable { $message = str($exception->getMessage())->lower(); diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index 6cdc651fb..a33368142 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -67,6 +67,43 @@ class ScheduledVolumeBackup extends BaseModel }); } + public function scopeForService(Builder $query, Service $service): Builder + { + $resources = $service->applications()->get()->concat($service->databases()->get()); + if ($resources->isEmpty()) { + return $query->whereRaw('1 = 0'); + } + $resourceIdsByType = $resources->groupBy(fn (Model $resource): string => $resource->getMorphClass()); + + $volumeIds = LocalPersistentVolume::query() + ->where(function (Builder $query) use ($resourceIdsByType): void { + foreach ($resourceIdsByType as $type => $resources) { + $query->orWhere(fn (Builder $query) => $query + ->where('resource_type', $type) + ->whereIn('resource_id', $resources->pluck('id'))); + } + })->pluck('id'); + $directoryIds = LocalFileVolume::query() + ->where('is_directory', true) + ->where('is_host_file', false) + ->where(function (Builder $query) use ($resourceIdsByType): void { + foreach ($resourceIdsByType as $type => $resources) { + $query->orWhere(fn (Builder $query) => $query + ->where('resource_type', $type) + ->whereIn('resource_id', $resources->pluck('id'))); + } + })->pluck('id'); + + return $query->where(function (Builder $query) use ($volumeIds, $directoryIds): void { + $query->where(fn (Builder $query) => $query + ->where('backupable_type', (new LocalPersistentVolume)->getMorphClass()) + ->whereIn('backupable_id', $volumeIds)) + ->orWhere(fn (Builder $query) => $query + ->where('backupable_type', (new LocalFileVolume)->getMorphClass()) + ->whereIn('backupable_id', $directoryIds)); + }); + } + public function backupable(): MorphTo { return $this->morphTo(); diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 67907423e..f95321d70 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -15,7 +15,10 @@ class SafeWebhookUrl implements ValidationRule /** * @param (Closure(string): array)|null $resolver */ - public function __construct(private ?Closure $resolver = null) {} + /** + * @param array $trustedInternalHosts + */ + public function __construct(private ?Closure $resolver = null, private array $trustedInternalHosts = []) {} /** * Run the validation rule. @@ -97,7 +100,7 @@ class SafeWebhookUrl implements ValidationRule * * @return array */ - public static function httpClientOptions(string $url): array + public static function httpClientOptions(string $url, array $trustedInternalHosts = []): array { $options = ['allow_redirects' => false]; @@ -105,7 +108,7 @@ class SafeWebhookUrl implements ValidationRule throw new \RuntimeException('Webhook URL DNS pinning is unavailable.'); } - $target = self::resolveUrlForRequest($url); + $target = self::resolveUrlForRequest($url, $trustedInternalHosts); if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { return $options; @@ -135,9 +138,9 @@ class SafeWebhookUrl implements ValidationRule * * @return array */ - public static function minioClientResolveOptions(string $url): array + public static function minioClientResolveOptions(string $url, array $trustedInternalHosts = []): array { - $target = self::resolveUrlForRequest($url); + $target = self::resolveUrlForRequest($url, $trustedInternalHosts); if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { return []; @@ -172,9 +175,9 @@ class SafeWebhookUrl implements ValidationRule /** * @return array{host: string, port: int, ips: array} */ - private static function resolveUrlForRequest(string $url): array + private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = []): array { - $rule = new self; + $rule = new self(trustedInternalHosts: $trustedInternalHosts); $host = parse_url($url, PHP_URL_HOST); if (! is_string($host) || $host === '') { throw new \RuntimeException('Webhook URL host could not be resolved.'); @@ -458,6 +461,10 @@ class SafeWebhookUrl implements ValidationRule private function isAllowedHostname(string $host): bool { + if (in_array($host, array_map('strtolower', $this->trustedInternalHosts), true)) { + return true; + } + foreach ($this->allowlistEntries() as $entry) { if (! str_contains($entry, '/') && strtolower($entry) === $host) { return true; diff --git a/lang/en.json b/lang/en.json index a81e1ee68..12c21b666 100644 --- a/lang/en.json +++ b/lang/en.json @@ -15,6 +15,7 @@ "auth.forgot_password_link": "Forgot password?", "auth.forgot_password_heading": "Password recovery", "auth.forgot_password_send_email": "Send password reset email", + "auth.forgot_password_disabled_tooltip": "Password reset is unavailable because transactional email (SMTP or Resend) is not configured on this instance.", "auth.register_now": "Register", "auth.logout": "Logout", "auth.register": "Register", diff --git a/resources/css/app.css b/resources/css/app.css index c7fd6c085..eab835f30 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -994,6 +994,7 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too /* Layer card header: compact strip on the shell, subtle title color */ .application-settings-section > :is(header, .application-settings-section-header) { display: flex; + min-height: 3rem; flex-wrap: wrap; align-items: center; justify-content: space-between; @@ -1029,30 +1030,18 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too padding: 1rem; } -/* Server pages share the application settings grid. The small desktop offset - aligns the first card edge with the visible sidebar section label. */ +/* All settings sidebars share one alignment rule. Their natural position is + level with the content column; once scrolled they stay below the top bar. */ .server-settings-workspace > :not(.application-settings-navigation) { min-width: 0; } @media (min-width: 1280px) { - .server-settings-workspace > :not(.application-settings-navigation) { - margin-top: 0.75rem; - } - - /* - * Settings / resource side nav stays pinned while the form column scrolls. - * top = primary header (3rem) + layer-2 bar (3rem) + hairline gap (0.5rem). - * max-height lets long menus scroll inside the pin instead of forcing the - * whole page to move the nav out of view. - */ .application-settings-navigation { position: sticky; - top: 6.5rem; + top: 3.5rem; align-self: start; - max-height: calc(100dvh - 7.25rem); - /* Inset content so the default ring-2 + ring-offset-2 focus ring is not - clipped by overflow-x on the right edge of this narrow column. */ + max-height: calc(100dvh - 4.25rem); padding-right: 0.375rem; overflow-x: hidden; overflow-y: auto; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 79821ad8a..829a26cad 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -34,9 +34,43 @@ @endenv
- - {{ __('auth.forgot_password_link') }} - + @if (is_transactional_emails_enabled()) + + {{ __('auth.forgot_password_link') }} + + @else + + + {{ __('auth.forgot_password_link') }} + +
+ {{ __('auth.forgot_password_disabled_tooltip') }} +
+
+ @endif
diff --git a/resources/views/components/backup-sidebar.blade.php b/resources/views/components/backup-sidebar.blade.php index 839747f45..839d22c48 100644 --- a/resources/views/components/backup-sidebar.blade.php +++ b/resources/views/components/backup-sidebar.blade.php @@ -22,6 +22,14 @@ 'executions' => 'project.service.database.backup.executions', 'danger' => 'project.service.database.backup.danger', ], + 'service-volume' => [ + 'back' => 'project.service.volume-backups.index', + 'general' => 'project.service.volume-backups.show', + 's3' => 'project.service.volume-backups.s3', + 'retention' => 'project.service.volume-backups.retention', + 'executions' => 'project.service.volume-backups.executions', + 'danger' => 'project.service.volume-backups.danger', + ], default => [ 'back' => 'project.database.backup.index', 'general' => 'project.database.backup.execution', @@ -41,7 +49,7 @@ ]; @endphp -