mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(ui): revert failed notification toggles and escape the 2FA setup URL once
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user