From 71b1e825ef87fc5d56147553be5f8bfb7a51d067 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 30 Sep 2026 18:10:10 +0200
Subject: [PATCH] fix(ui): revert failed notification toggles and escape the
2FA setup URL once
---
.../Concerns/TogglesNotificationEvents.php | 10 ++-
app/Livewire/Notifications/Discord.php | 6 +-
app/Livewire/Notifications/Pushover.php | 2 +-
app/Livewire/Notifications/Slack.php | 2 +-
app/Livewire/Notifications/Telegram.php | 2 +-
app/Livewire/Notifications/Webhook.php | 3 +-
.../notification/channel-actions.blade.php | 1 +
.../views/livewire/profile/index.blade.php | 4 +-
tests/Feature/EnableActionButtonsTest.php | 67 +++++++++++++++++++
tests/Feature/ProfileTwoFactorSetupTest.php | 64 ++++++++++++++++++
10 files changed, 149 insertions(+), 12 deletions(-)
create mode 100644 tests/Feature/ProfileTwoFactorSetupTest.php
diff --git a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php
index 4433de0242..37291ba59b 100644
--- a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php
+++ b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php
@@ -33,6 +33,14 @@ trait TogglesNotificationEvents
abort_unless(in_array($property, $allowedProperties, true), 404);
$this->{$property} = ! $this->{$property};
- $this->saveModel();
+
+ try {
+ $this->saveModel();
+ } catch (\Throwable $e) {
+ $this->{$property} = ! $this->{$property};
+ $this->settings->refresh();
+
+ handleError($e, $this);
+ }
}
}
diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php
index 8e0e149e27..469e47605c 100644
--- a/app/Livewire/Notifications/Discord.php
+++ b/app/Livewire/Notifications/Discord.php
@@ -143,13 +143,12 @@ class Discord extends Component
public function instantSaveDiscordPingEnabled()
{
try {
- $original = $this->discordPingEnabled;
$this->validate([
'discordPingEnabled' => 'required',
]);
$this->saveModel();
} catch (\Throwable $e) {
- $this->discordPingEnabled = $original;
+ $this->discordPingEnabled = (bool) $this->settings->refresh()->discord_ping_enabled;
return handleError($e, $this);
}
@@ -158,7 +157,6 @@ class Discord extends Component
public function instantSaveDiscordEnabled()
{
try {
- $original = $this->discordEnabled;
$this->validate([
'discordWebhookUrl' => 'required',
], [
@@ -166,7 +164,7 @@ class Discord extends Component
]);
$this->saveModel();
} catch (\Throwable $e) {
- $this->discordEnabled = $original;
+ $this->discordEnabled = (bool) $this->settings->refresh()->discord_enabled;
return handleError($e, $this);
}
diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php
index 5e9abd9407..a2fe63a0f8 100644
--- a/app/Livewire/Notifications/Pushover.php
+++ b/app/Livewire/Notifications/Pushover.php
@@ -157,7 +157,7 @@ class Pushover extends Component
]);
$this->saveModel();
} catch (\Throwable $e) {
- $this->pushoverEnabled = false;
+ $this->pushoverEnabled = (bool) $this->settings->refresh()->pushover_enabled;
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php
index a96b452f82..70ec6f786e 100644
--- a/app/Livewire/Notifications/Slack.php
+++ b/app/Livewire/Notifications/Slack.php
@@ -148,7 +148,7 @@ class Slack extends Component
]);
$this->saveModel();
} catch (\Throwable $e) {
- $this->slackEnabled = false;
+ $this->slackEnabled = (bool) $this->settings->refresh()->slack_enabled;
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php
index 6b362f7869..cd7bea60cd 100644
--- a/app/Livewire/Notifications/Telegram.php
+++ b/app/Livewire/Notifications/Telegram.php
@@ -257,7 +257,7 @@ class Telegram extends Component
]);
$this->saveModel();
} catch (\Throwable $e) {
- $this->telegramEnabled = false;
+ $this->telegramEnabled = (bool) $this->settings->refresh()->telegram_enabled;
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php
index dfc0fabd30..9b20695cc7 100644
--- a/app/Livewire/Notifications/Webhook.php
+++ b/app/Livewire/Notifications/Webhook.php
@@ -136,7 +136,6 @@ class Webhook extends Component
public function instantSaveWebhookEnabled()
{
try {
- $original = $this->webhookEnabled;
$this->validate([
'webhookUrl' => 'required',
], [
@@ -144,7 +143,7 @@ class Webhook extends Component
]);
$this->saveModel();
} catch (\Throwable $e) {
- $this->webhookEnabled = $original;
+ $this->webhookEnabled = (bool) $this->settings->refresh()->webhook_enabled;
return handleError($e, $this);
}
diff --git a/resources/views/components/notification/channel-actions.blade.php b/resources/views/components/notification/channel-actions.blade.php
index b4ad3c8bda..8a070f8d29 100644
--- a/resources/views/components/notification/channel-actions.blade.php
+++ b/resources/views/components/notification/channel-actions.blade.php
@@ -24,6 +24,7 @@
enabled = next;
$wire.$set(enabledProperty, next)
.then(() => $wire.$call(toggleMethod))
+ .then(() => { enabled = Boolean($wire.$get(enabledProperty)); })
.catch(() => { enabled = !next; });
">
{{ $enabled ? 'Disable' : 'Enable' }}
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php
index 32f0421926..97e6ff74ee 100644
--- a/resources/views/livewire/profile/index.blade.php
+++ b/resources/views/livewire/profile/index.blade.php
@@ -260,8 +260,8 @@
-
+ :text="decrypt(request()->user()->two_factor_secret)" />
+
diff --git a/tests/Feature/EnableActionButtonsTest.php b/tests/Feature/EnableActionButtonsTest.php
index 3cc044b886..afcb340063 100644
--- a/tests/Feature/EnableActionButtonsTest.php
+++ b/tests/Feature/EnableActionButtonsTest.php
@@ -142,3 +142,70 @@ it('keeps notification email smtp disabled when enable validation fails', functi
->assertDispatched('error')
->assertSet('smtpEnabled', false);
});
+
+it('reverts a notification provider toggle to the saved value when enabling fails', function (string $component, string $method, string $enabledProperty, string $settingsRelation, string $settingsColumn) {
+ [, $team] = actingAsEnableActionOwner();
+
+ Livewire::test($component)
+ ->set($enabledProperty, true)
+ ->call($method)
+ ->assertDispatched('error')
+ ->assertSet($enabledProperty, false);
+
+ expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse();
+})->with([
+ 'discord' => [Discord::class, 'instantSaveDiscordEnabled', 'discordEnabled', 'discordNotificationSettings', 'discord_enabled'],
+ 'slack' => [Slack::class, 'instantSaveSlackEnabled', 'slackEnabled', 'slackNotificationSettings', 'slack_enabled'],
+ 'telegram' => [Telegram::class, 'instantSaveTelegramEnabled', 'telegramEnabled', 'telegramNotificationSettings', 'telegram_enabled'],
+ 'pushover' => [Pushover::class, 'instantSavePushoverEnabled', 'pushoverEnabled', 'pushoverNotificationSettings', 'pushover_enabled'],
+ 'webhook' => [Webhook::class, 'instantSaveWebhookEnabled', 'webhookEnabled', 'webhookNotificationSettings', 'webhook_enabled'],
+]);
+
+it('reverts a notification provider toggle to the saved value when disabling fails', function (string $component, string $method, string $enabledProperty, string $requiredProperty, string $settingsRelation, array $savedSettings) {
+ [, $team] = actingAsEnableActionOwner();
+ $team->{$settingsRelation}->update($savedSettings);
+
+ Livewire::test($component)
+ ->assertSet($enabledProperty, true)
+ ->set($requiredProperty, '')
+ ->set($enabledProperty, false)
+ ->call($method)
+ ->assertDispatched('error')
+ ->assertSet($enabledProperty, true);
+
+ expect($team->{$settingsRelation}->fresh()->{array_key_first($savedSettings)})->toBeTrue();
+})->with([
+ 'discord' => [Discord::class, 'instantSaveDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', ['discord_enabled' => true, 'discord_webhook_url' => 'https://discord.com/api/webhooks/1/abc']],
+ 'slack' => [Slack::class, 'instantSaveSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', ['slack_enabled' => true, 'slack_webhook_url' => 'https://hooks.slack.com/services/T/B/C']],
+ 'telegram' => [Telegram::class, 'instantSaveTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', ['telegram_enabled' => true, 'telegram_token' => '123:abc', 'telegram_chat_id' => '42']],
+ 'pushover' => [Pushover::class, 'instantSavePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', ['pushover_enabled' => true, 'pushover_user_key' => 'user', 'pushover_api_token' => 'token']],
+ 'webhook' => [Webhook::class, 'instantSaveWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', ['webhook_enabled' => true, 'webhook_url' => 'https://hooks.example.com/coolify']],
+]);
+
+it('reverts a notification event toggle to the saved value when saving fails', function (string $component, string $urlProperty, string $eventProperty, string $settingsRelation, string $eventColumn) {
+ [, $team] = actingAsEnableActionOwner();
+ $saved = (bool) $team->{$settingsRelation}->{$eventColumn};
+
+ Livewire::test($component)
+ ->set($urlProperty, 'not-a-url')
+ ->call('toggleEvent', $eventProperty)
+ ->assertSet($eventProperty, $saved);
+
+ expect((bool) $team->{$settingsRelation}->fresh()->{$eventColumn})->toBe($saved);
+})->with([
+ 'discord' => [Discord::class, 'discordWebhookUrl', 'deploymentSuccessDiscordNotifications', 'discordNotificationSettings', 'deployment_success_discord_notifications'],
+ 'slack' => [Slack::class, 'slackWebhookUrl', 'deploymentSuccessSlackNotifications', 'slackNotificationSettings', 'deployment_success_slack_notifications'],
+]);
+
+it('reverts the discord mention setting to the saved value when saving fails', function () {
+ [, $team] = actingAsEnableActionOwner();
+ $team->discordNotificationSettings->update(['discord_ping_enabled' => true]);
+
+ Livewire::test(Discord::class)
+ ->set('discordWebhookUrl', 'not-a-url')
+ ->set('discordPingEnabled', false)
+ ->call('instantSaveDiscordPingEnabled')
+ ->assertSet('discordPingEnabled', true);
+
+ expect($team->discordNotificationSettings->fresh()->discord_ping_enabled)->toBeTrue();
+});
diff --git a/tests/Feature/ProfileTwoFactorSetupTest.php b/tests/Feature/ProfileTwoFactorSetupTest.php
new file mode 100644
index 0000000000..f6a084d62a
--- /dev/null
+++ b/tests/Feature/ProfileTwoFactorSetupTest.php
@@ -0,0 +1,64 @@
+loadHTML(''.$html);
+ $xpath = new DOMXPath($document);
+
+ $input = null;
+ foreach ($xpath->query('//input[@readonly]') as $node) {
+ if (str_starts_with(html_entity_decode($node->getAttribute('value')), 'otpauth')) {
+ $input = $node;
+ break;
+ }
+ }
+ expect($input)->not->toBeNull();
+
+ preg_match('/@click="copy\(await \(([^"]*otpauth[^"]*)\)\)"/', $html, $matches);
+ expect($matches)->toHaveKey(1);
+ $javascriptString = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5);
+
+ return [
+ 'input' => $input->getAttribute('value'),
+ 'copied' => json_decode('"'.str_replace("\\'", "'", trim($javascriptString, "'")).'"'),
+ ];
+}
+
+it('renders the 2fa setup url escaped exactly once', function () {
+ config()->set('app.name', 'Coolify & Co');
+
+ $user = User::factory()->create(['name' => 'Two Factor User', 'email' => 'first+2fa@example.com']);
+ $user->forceFill([
+ 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()),
+ ])->save();
+
+ $this->actingAs($user);
+ session(['status' => 'two-factor-authentication-enabled']);
+
+ $expectedUrl = $user->fresh()->twoFactorQrCodeUrl();
+ $rendered = renderedTwoFactorSetupUrl(Livewire::test(ProfileIndex::class)->html());
+
+ expect($rendered['input'])->toBe($expectedUrl)
+ ->and($rendered['copied'])->toBe($expectedUrl);
+
+ $parts = parse_url($rendered['copied']);
+ parse_str($parts['query'], $query);
+
+ expect($parts['scheme'])->toBe('otpauth')
+ ->and($parts['host'])->toBe('totp')
+ ->and(rawurldecode(ltrim($parts['path'], '/')))->toBe('Coolify & Co:first+2fa@example.com')
+ ->and($query['issuer'])->toBe('Coolify & Co')
+ ->and($query['secret'])->toBe(decrypt($user->fresh()->two_factor_secret));
+});