diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php
new file mode 100644
index 000000000..453a7e8ae
--- /dev/null
+++ b/app/Livewire/Security/IntegrationTokenEditor.php
@@ -0,0 +1,114 @@
+integrationToken = IntegrationToken::ownedByCurrentTeam()
+ ->whereUuid($integration_token_uuid)
+ ->firstOrFail();
+
+ $this->authorize('view', $this->integrationToken);
+
+ $this->name = $this->integrationToken->name;
+ $this->capabilities = $this->integrationToken->capabilities;
+ }
+
+ protected function rules(): array
+ {
+ return [
+ 'name' => ['required', 'string', 'max:255'],
+ 'newToken' => ['nullable', 'string'],
+ 'capabilities' => ['required', 'array', 'min:1'],
+ 'capabilities.*' => ['required', 'in:dns'],
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return [
+ 'capabilities.required' => 'Select at least one capability.',
+ 'capabilities.min' => 'Select at least one capability.',
+ ];
+ }
+
+ public function save(CloudflareTokenValidator $validator): void
+ {
+ $this->authorize('update', $this->integrationToken);
+ $validated = $this->validate();
+ $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
+ $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
+ !== collect($this->integrationToken->capabilities)->sort()->values()->all();
+
+ try {
+ if ((filled($validated['newToken']) || $capabilitiesChanged)
+ && ! $validator->validate($token, $validated['capabilities'])) {
+ $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
+
+ return;
+ }
+
+ $updates = [
+ 'name' => $validated['name'],
+ 'capabilities' => $validated['capabilities'],
+ ];
+
+ if (filled($validated['newToken'])) {
+ $updates['token'] = $validated['newToken'];
+ }
+
+ $this->integrationToken->update($updates);
+ $this->newToken = '';
+
+ auditLog('ui.integration_token.updated', [
+ 'team_id' => currentTeam()->id,
+ 'integration_token_uuid' => $this->integrationToken->uuid,
+ 'integration_token_name' => $this->integrationToken->name,
+ 'provider' => $this->integrationToken->provider,
+ 'rotated' => array_key_exists('token', $updates),
+ ]);
+
+ $this->dispatch(
+ 'integration-token-updated',
+ uuid: $this->integrationToken->uuid,
+ name: $this->integrationToken->name,
+ capabilities: $this->integrationToken->capabilities,
+ );
+ $this->dispatch('success', 'Integration token updated successfully.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function delete(string $password = ''): void
+ {
+ $this->authorize('delete', $this->integrationToken);
+ $this->integrationToken->delete();
+
+ $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
+ $this->dispatch('close-modal');
+ $this->dispatch('success', 'Integration token deleted successfully.');
+ }
+
+ public function render()
+ {
+ return view('livewire.security.integration-token-editor');
+ }
+}
diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php
index 8dfc98eb8..309c8167f 100644
--- a/app/Policies/IntegrationTokenPolicy.php
+++ b/app/Policies/IntegrationTokenPolicy.php
@@ -17,6 +17,16 @@ public function create(User $user): bool
return $user->isAdmin();
}
+ public function view(User $user, IntegrationToken $integrationToken): bool
+ {
+ return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
+ }
+
+ public function update(User $user, IntegrationToken $integrationToken): bool
+ {
+ return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
+ }
+
public function delete(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php
new file mode 100644
index 000000000..b7e53dbc7
--- /dev/null
+++ b/resources/views/livewire/security/integration-token-editor.blade.php
@@ -0,0 +1,52 @@
+
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php
index e4f1ba1d6..b4961551a 100644
--- a/resources/views/livewire/security/integration-tokens.blade.php
+++ b/resources/views/livewire/security/integration-tokens.blade.php
@@ -30,34 +30,50 @@
@foreach ($tokens as $savedToken)
-
-
- {{ $savedToken->name }}
-
-
-
- {{ ucfirst($savedToken->provider) }}
-
-
- @foreach ($savedToken->capabilities ?? [] as $capability)
-
- {{ $capability }}
-
- @endforeach
-
-
-
-
@endforeach
diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php
index ba2264149..113fa4c03 100644
--- a/tests/Feature/Security/IntegrationTokenFormTest.php
+++ b/tests/Feature/Security/IntegrationTokenFormTest.php
@@ -1,5 +1,6 @@
toContain('wire:target="addToken" isHighlighted')
->not->toContain('class="button-highlighted"');
});
+
+test('saved integration token rows render modal editors with a gear button', function () {
+ IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokens::class)
+ ->assertSee('Edit Integration Token')
+ ->assertSee('Production DNS')
+ ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"');
+});
+
+test('an integration token can be rotated after validating its capabilities', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
+ 'success' => true,
+ 'result' => ['status' => 'active'],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'zone-id']],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
+ 'success' => true,
+ 'result' => [],
+ ]),
+ ]);
+
+ $savedToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->set('name', 'Rotated DNS')
+ ->set('newToken', 'rotated-token')
+ ->call('save')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ $savedToken->refresh();
+
+ expect($savedToken->name)->toBe('Rotated DNS')
+ ->and($savedToken->token)->toBe('rotated-token');
+});
+
+test('leaving the token field blank keeps the existing integration token', function () {
+ Http::fake();
+
+ $savedToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->set('name', 'Renamed DNS')
+ ->set('newToken', '')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $savedToken->refresh();
+
+ expect($savedToken->name)->toBe('Renamed DNS')
+ ->and($savedToken->token)->toBe('original-token');
+
+ Http::assertNothingSent();
+});
+
+test('an invalid replacement does not rotate the integration token', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
+ 'success' => false,
+ ], 403),
+ ]);
+
+ $savedToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->set('newToken', 'invalid-token')
+ ->call('save')
+ ->assertDispatched('error');
+
+ expect($savedToken->fresh()->token)->toBe('original-token');
+});
+
+test('editor updates its row without rerendering the teleported parent modal', function () {
+ $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php'));
+
+ expect($component)
+ ->toContain("'integration-token-updated'")
+ ->toContain("'integration-token-deleted'")
+ ->not->toContain('integrationTokenChanged');
+});