fix(ui): revert failed notification toggles and escape the 2FA setup URL once

This commit is contained in:
Andras Bacsai
2026-09-30 18:10:10 +02:00
parent 41eb524d3e
commit 71b1e825ef
10 changed files with 149 additions and 12 deletions
@@ -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);
}
}
}
+2 -4
View File
@@ -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);
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -2
View File
@@ -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);
}
@@ -24,6 +24,7 @@
enabled = next;
$wire.$set(enabledProperty, next)
.then(() => $wire.$call(toggleMethod))
.then(() => { enabled = Boolean($wire.$get(enabledProperty)); })
.catch(() => { enabled = !next; });
">
<span x-text="enabled ? 'Disable' : 'Enable'">{{ $enabled ? 'Disable' : 'Enable' }}</span>
@@ -260,8 +260,8 @@
<div x-data="{ showCode: false }">
<div x-cloak x-show="showCode" class="space-y-2 pb-3">
<x-forms.copy-input
text="{{ decrypt(request()->user()->two_factor_secret) }}" />
<x-forms.copy-input text="{{ request()->user()->twoFactorQrCodeUrl() }}" />
:text="decrypt(request()->user()->two_factor_secret)" />
<x-forms.copy-input :text="request()->user()->twoFactorQrCodeUrl()" />
</div>
<x-forms.button type="button" x-on:click="showCode = !showCode">
<span x-text="showCode ? 'Hide manual setup' : 'Show manual setup'"></span>
+67
View File
@@ -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();
});
@@ -0,0 +1,64 @@
<?php
use App\Livewire\Profile\Index as ProfileIndex;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* @return array{input: string, copied: string}
*/
function renderedTwoFactorSetupUrl(string $html): array
{
$document = new DOMDocument;
@$document->loadHTML('<?xml encoding="utf-8" ?>'.$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));
});