fix(webhooks): save manual webhook secret when generated

Generate now stores the new secret at once. The unsaved bar compares
fields with the saved secrets, so other drafts stay marked as unsaved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-18 11:24:52 +02:00
co-authored by Claude Opus 5.5
parent 6964714b5d
commit 4e7d73e6e2
3 changed files with 46 additions and 27 deletions
+25 -5
View File
@@ -42,6 +42,14 @@ class Webhooks extends Component
public ?string $giteaManualWebhookSecret = null;
/**
* Stored secret per component property. The unsaved bar compares the fields
* with these values, because Livewire syncs unsaved field edits with every request.
*
* @var array<string, string|null>
*/
public array $savedSecrets = [];
/**
* Maps each manual webhook provider to its component property and model attribute.
*
@@ -63,6 +71,7 @@ class Webhooks extends Component
$this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab');
$this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket');
$this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea');
$this->syncSavedSecrets();
}
$this->githubManualWebhook = generateGitManualWebhook($this->resource, 'github');
@@ -91,6 +100,7 @@ class Webhooks extends Component
'manual_webhook_secret_bitbucket' => $this->bitbucketManualWebhookSecret,
'manual_webhook_secret_gitea' => $this->giteaManualWebhookSecret,
]);
$this->syncSavedSecrets();
$this->dispatch('success', 'Webhook secrets saved.');
} catch (ValidationException $e) {
throw $e;
@@ -100,10 +110,10 @@ class Webhooks extends Component
}
/**
* Return a random secret for the provider's field. The browser fills the field
* as an unsaved change, and the user saves it with the form.
* Generate a random secret for the provider and save it immediately.
* Other unsaved secret edits in the form are kept as they are.
*/
public function generateSecret(string $provider): ?string
public function generateSecret(string $provider): void
{
try {
$this->authorize('update', $this->resource);
@@ -112,11 +122,21 @@ class Webhooks extends Component
throw new \InvalidArgumentException('Unknown webhook provider.');
}
return Str::random(40);
$field = self::SECRET_FIELDS[$provider];
$secret = Str::random(40);
$this->resource->update([$field['attribute'] => $secret]);
$this->{$field['property']} = $secret;
$this->savedSecrets[$field['property']] = $secret;
$this->dispatch('success', "New {$field['label']} generated and saved.");
} catch (\Exception $e) {
handleError($e, $this);
}
}
return null;
private function syncSavedSecrets(): void
{
foreach (self::SECRET_FIELDS as $field) {
$this->savedSecrets[$field['property']] = data_get($this->resource, $field['attribute']);
}
}
@@ -50,7 +50,8 @@
@if ($githubManualWebhook && $gitlabManualWebhook)
<form wire:submit.prevent="submit" class="application-settings-form flex flex-col">
<x-unsaved-bar action="submit" />
<x-unsaved-bar action="submit"
dirty="Object.entries($wire.savedSecrets).some(([property, saved]) => ($wire[property] ?? '') !== (saved ?? ''))" />
<x-application.settings-section id="manual-git-webhooks-section" title="Manual Git webhooks"
helper="Configure these endpoints when the repository is not connected through an official Git App." flush>
<x-slot:actions>
@@ -84,7 +85,7 @@
helper="Must exactly match the secret configured in {{ $provider['name'] }}. New secrets need at least 16 characters. Leave empty to disable this webhook."
autocomplete="new-password" copyable />
<x-forms.button type="button"
x-on:click="$wire.generateSecret('{{ $provider['key'] }}').then((secret) => { if (typeof secret === 'string') { $wire.set('{{ $provider['secret'] }}', secret, false); } })"
wire:click="generateSecret('{{ $provider['key'] }}')"
aria-label="Generate {{ $provider['name'] }} webhook secret">
Generate
</x-forms.button>
@@ -112,34 +112,30 @@ describe('Webhooks component', function () {
expect($this->application->fresh()->manual_webhook_secret_github)->toBeEmpty();
});
test('generate returns a random secret of at least 32 characters that saves with the form', function () {
$generated = null;
test('generate saves a random secret of at least 32 characters for that provider only', function () {
$component = manualWebhookSecretComponent()
->set('githubManualWebhookSecret', 'unsaved-github-edit')
->call('generateSecret', 'gitlab')
->assertHasNoErrors()
->assertReturned(function ($secret) use (&$generated) {
$generated = $secret;
return is_string($secret) && strlen($secret) >= 32;
})
->assertSet('gitlabManualWebhookSecret', 'gitlab-legacy');
expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe('gitlab-legacy');
$component->set('gitlabManualWebhookSecret', $generated)
->call('submit')
->assertHasNoErrors()
->assertDispatched('success');
expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe($generated);
$generated = $component->get('gitlabManualWebhookSecret');
$application = $this->application->fresh();
expect($generated)->toBeString()->not->toBe('gitlab-legacy')
->and(strlen($generated))->toBeGreaterThanOrEqual(32)
->and($application->manual_webhook_secret_gitlab)->toBe($generated)
->and($application->manual_webhook_secret_github)->toBe('short-legacy');
$component->assertSet('githubManualWebhookSecret', 'unsaved-github-edit')
->assertSet('savedSecrets.gitlabManualWebhookSecret', $generated)
->assertSet('savedSecrets.githubManualWebhookSecret', 'short-legacy');
});
test('generate rejects an unknown provider', function () {
manualWebhookSecretComponent()
->call('generateSecret', 'deploywebhook')
->assertDispatched('error')
->assertReturned(null);
->assertDispatched('error');
});
test('shows generate buttons only to users who can edit secrets', function () {
@@ -158,9 +154,11 @@ describe('Webhooks component', function () {
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('savedSecrets', [])
->call('generateSecret', 'github')
->assertDispatched('error')
->assertReturned(null);
->assertDispatched('error');
expect($this->application->fresh()->manual_webhook_secret_github)->toBe('short-legacy');
});
});