feat(platform): support SMTP EHLO, page-preserving teams, and upgrades
This commit is contained in:
parent
849ba8f34c
commit
22f0af8d0e
10 changed files with 120 additions and 15 deletions
|
|
@ -11,6 +11,7 @@
|
|||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -37,6 +38,7 @@ private function channelConfig(string $channel): array
|
|||
'smtp_username' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_password' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
|
||||
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
'use_instance_email_settings' => 'sometimes|boolean',
|
||||
|
|
@ -283,7 +285,7 @@ private function updateChannel(Request $request, string $channel): JsonResponse
|
|||
|
||||
#[OA\Get(
|
||||
summary: 'Get email notification settings',
|
||||
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/email',
|
||||
operationId: 'get-current-team-email-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
|
|
@ -301,7 +303,7 @@ public function email(Request $request): JsonResponse
|
|||
|
||||
#[OA\Patch(
|
||||
summary: 'Update email notification settings',
|
||||
description: 'Update the current team email notification settings.',
|
||||
description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.',
|
||||
path: '/notifications/email',
|
||||
operationId: 'update-current-team-email-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public function updatedSelectedTeamId()
|
|||
$this->switch_to($this->selectedTeamId);
|
||||
}
|
||||
|
||||
public function switch_to($team_id)
|
||||
public function switch_to($team_id, ?string $currentUrl = null)
|
||||
{
|
||||
if (! auth()->user()->teams->contains($team_id)) {
|
||||
return;
|
||||
|
|
@ -30,6 +30,12 @@ public function switch_to($team_id)
|
|||
}
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
return redirect('dashboard');
|
||||
$parsedUrl = parse_url($currentUrl ?? '/dashboard');
|
||||
$redirectUrl = data_get($parsedUrl, 'path', '/dashboard');
|
||||
if ($query = data_get($parsedUrl, 'query')) {
|
||||
$redirectUrl .= '?'.$query;
|
||||
}
|
||||
|
||||
return redirect($redirectUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ class Upgrade extends Component
|
|||
|
||||
public bool $devMode = false;
|
||||
|
||||
public bool $fullButton = false;
|
||||
|
||||
protected $listeners = ['updateAvailable' => 'checkUpdate'];
|
||||
|
||||
public function mount()
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-10
|
|||
<div id="server-timing-hud-slot-mobile" data-server-timing-hud-slot
|
||||
class="hidden shrink-0 items-center"></div>
|
||||
<div id="configuration-warning-hud-slot-mobile" class="relative shrink-0"></div>
|
||||
@if (isInstanceAdmin() && !isCloud())
|
||||
<livewire:upgrade key="mobile-upgrade" />
|
||||
@endif
|
||||
<x-top-user-menu />
|
||||
<button type="button" class="-m-1 p-2 text-neutral-500 dark:text-fg-dim" x-on:click="open = !open">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@
|
|||
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}}
|
||||
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency" />
|
||||
|
||||
<x-application.settings-section title="Update Coolify"
|
||||
helper="Install the latest Coolify version manually when an update is available.">
|
||||
<livewire:upgrade :full-button="true" key="settings-upgrade" />
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Update checks">
|
||||
<x-slot:actions>
|
||||
<x-forms.button type="button" wire:click="checkManually">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-
|
|||
Teams
|
||||
</div>
|
||||
@foreach (auth()->user()->teams as $team)
|
||||
<button type="button" wire:click="switch_to({{ $team->id }})" @click="open = false"
|
||||
<button type="button" wire:click="switch_to({{ $team->id }}, window.location.href)" @click="open = false"
|
||||
class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-medium dark:bg-white/[0.06]' : '' }}">
|
||||
<span class="min-w-0 flex-1 truncate">{{ $team->name }}</span>
|
||||
</button>
|
||||
|
|
@ -66,7 +66,7 @@ class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-
|
|||
Teams
|
||||
</div>
|
||||
@foreach (auth()->user()->teams as $team)
|
||||
<button type="button" wire:click="switch_to({{ $team->id }})" @click="teamOpen = false"
|
||||
<button type="button" wire:click="switch_to({{ $team->id }}, window.location.href)" @click="teamOpen = false"
|
||||
class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-medium dark:bg-white/[0.06]' : '' }}">
|
||||
<span class="min-w-0 flex-1 truncate">{{ $team->name }}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@
|
|||
})">
|
||||
@if ($isUpgradeAvailable)
|
||||
<div :class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
|
||||
@if ($fullButton)
|
||||
<x-forms.button type="button" @click="modalOpen=true" x-show="!showProgress" x-cloak isHighlighted>
|
||||
Upgrade now
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" @click="modalOpen=true" x-show="showProgress" x-cloak isHighlighted>
|
||||
Updating…
|
||||
</x-forms.button>
|
||||
@else
|
||||
<button type="button" title="Upgrade in progress" aria-label="Upgrade in progress"
|
||||
@click="modalOpen=true" x-show="showProgress" x-cloak
|
||||
class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs/10 px-1.5 text-[9.5px] font-semibold leading-none text-coollabs ring-1 ring-inset ring-coollabs/25 transition-colors hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20">
|
||||
|
|
@ -16,6 +24,7 @@ class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs
|
|||
class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs/10 px-1.5 text-[9.5px] font-semibold leading-none text-coollabs ring-1 ring-inset ring-coollabs/25 transition-colors hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20">
|
||||
Update available
|
||||
</button>
|
||||
@endif
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4"
|
||||
|
|
@ -162,6 +171,8 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
|
|||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@elseif ($fullButton)
|
||||
<p class="text-sm text-neutral-600 dark:text-fg-dim">Coolify is up to date.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function authHeaders(string $token): array
|
|||
$response->assertJsonStructure([
|
||||
'team_id',
|
||||
'smtp_enabled',
|
||||
'smtp_ehlo_domain',
|
||||
'deployment_failure_email_notifications',
|
||||
'use_instance_email_settings',
|
||||
]);
|
||||
|
|
@ -171,6 +172,7 @@ function authHeaders(string $token): array
|
|||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'alerts@example.com',
|
||||
'smtp_host' => 'smtp.example.com',
|
||||
'smtp_ehlo_domain' => 'coolify.example.com',
|
||||
'smtp_port' => 587,
|
||||
'smtp_encryption' => 'starttls',
|
||||
'deployment_failure_email_notifications' => false,
|
||||
|
|
@ -178,16 +180,42 @@ function authHeaders(string $token): array
|
|||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('smtp_enabled', true);
|
||||
$response->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com');
|
||||
$response->assertJsonPath('deployment_failure_email_notifications', false);
|
||||
|
||||
$settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first();
|
||||
expect($settings->smtp_enabled)->toBeTrue()
|
||||
->and($settings->smtp_from_address)->toBe('alerts@example.com')
|
||||
->and($settings->smtp_host)->toBe('smtp.example.com')
|
||||
->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com')
|
||||
->and($settings->smtp_port)->toBe(587)
|
||||
->and($settings->deployment_failure_email_notifications)->toBeFalse();
|
||||
});
|
||||
|
||||
test('validates the smtp ehlo domain', function () {
|
||||
$this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_ehlo_domain' => 'not a hostname',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('smtp_ehlo_domain');
|
||||
});
|
||||
|
||||
test('clears the smtp ehlo domain', function () {
|
||||
$this->team->emailNotificationSettings->update([
|
||||
'smtp_ehlo_domain' => 'coolify.example.com',
|
||||
]);
|
||||
|
||||
$this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_ehlo_domain' => null,
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('smtp_ehlo_domain', null);
|
||||
|
||||
expect($this->team->emailNotificationSettings->fresh()->smtp_ehlo_domain)->toBeNull();
|
||||
});
|
||||
|
||||
test('updates discord notification settings', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/discord', [
|
||||
|
|
|
|||
32
tests/Feature/SwitchTeamTest.php
Normal file
32
tests/Feature/SwitchTeamTest.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\SwitchTeam;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->currentTeam = $this->user->teams()->first();
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->currentTeam]);
|
||||
});
|
||||
|
||||
test('switching teams keeps the current page URL', function () {
|
||||
$currentUrl = route('security.api-tokens', ['page' => 2]);
|
||||
|
||||
Livewire::test(SwitchTeam::class)
|
||||
->call('switch_to', $this->otherTeam->id, $currentUrl)
|
||||
->assertRedirect($currentUrl);
|
||||
|
||||
expect(session('currentTeam')->is($this->otherTeam))->toBeTrue();
|
||||
});
|
||||
|
|
@ -64,18 +64,34 @@
|
|||
->not->toContain('Lost contact with Coolify');
|
||||
});
|
||||
|
||||
it('uses sidebar state css instead of nested alpine state for upgrade labels', function () {
|
||||
it('renders the upgrade control in the mobile top bar', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toMatch('/MOBILE TOP BAR[\s\S]*?<livewire:upgrade[^>]*>[\s\S]*?Open sidebar/')
|
||||
->toContain('key="mobile-upgrade"');
|
||||
});
|
||||
|
||||
it('supports a full size update button for settings', function () {
|
||||
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
|
||||
$utilitiesCss = file_get_contents(resource_path('css/utilities.css'));
|
||||
$settingsView = file_get_contents(resource_path('views/livewire/settings/updates.blade.php'));
|
||||
|
||||
expect($upgradeView)
|
||||
->toContain('class="text-left menu-item-label sidebar-collapsed-label"')
|
||||
->toContain('>In progress</span>')
|
||||
->toContain('>Upgrade</span>')
|
||||
->not->toContain(':class="collapsed && \'lg:hidden\'"')
|
||||
->and($utilitiesCss)
|
||||
->toContain('.sidebar-collapsed .sidebar-collapsed-label')
|
||||
->toContain('display: none;');
|
||||
->toContain('$fullButton')
|
||||
->toContain('Upgrade now')
|
||||
->and($settingsView)
|
||||
->toContain('Update Coolify')
|
||||
->toContain(':full-button="true"')
|
||||
->toContain('key="settings-upgrade"');
|
||||
});
|
||||
|
||||
it('uses compact labels that do not depend on desktop sidebar state', function () {
|
||||
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
|
||||
|
||||
expect($upgradeView)
|
||||
->toContain('Updating')
|
||||
->toContain('Update available')
|
||||
->not->toContain(':class="collapsed');
|
||||
});
|
||||
|
||||
it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue