From 59481c7bb6cd71c08c4f0b54ef1789ee304b9b3c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:29:24 +0100 Subject: [PATCH] fix(api-tokens): keep token search in sync after create, edit, and revoke Closes #12465 Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Security/ApiTokens.php | 30 ++++++++++++++++++- .../livewire/security/api-tokens.blade.php | 11 ++----- .../ApiTokenLivewireAuthorizationTest.php | 24 +++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 73df4a8539..566e42a0d2 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -10,7 +10,9 @@ use App\Models\Project; use App\Services\Access\AccessResolver; use App\Services\Access\Grant; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Laravel\Sanctum\PersonalAccessToken; use Livewire\Attributes\Computed; @@ -30,6 +32,14 @@ class ApiTokens extends Component public $tokens = []; + /** + * Search text of each listed token. Alpine filters and pages the list with it through $wire, so it must change with $tokens. + * + * @var list + */ + #[Locked] + public array $tokenSearchIndex = []; + public array $expirationOptions = [ 7 => '7 days', 30 => '30 days', @@ -59,10 +69,18 @@ class ApiTokens extends Component public function render() { return view('livewire.security.api-tokens', [ - 'scopeLabels' => collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label', 'value'), + 'scopeLabels' => $this->scopeLabels(), ]); } + /** + * @return Collection + */ + private function scopeLabels(): Collection + { + return collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label', 'value'); + } + /** * @param list $selected * @return list @@ -149,6 +167,16 @@ class ApiTokens extends Component ->where('team_id', currentTeam()->id) ->latest() ->get(); + + $scopeLabels = $this->scopeLabels(); + $this->tokenSearchIndex = $this->tokens->map(fn (PersonalAccessToken $token) => [ + 'id' => (string) $token->id, + 'search' => Str::lower(collect([$token->name, ...($token->abilities ?? [])]) + ->merge($token->scopes === null + ? ['All resources in the team'] + : collect($token->scopes)->map(fn ($scope) => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? null)->filter()) + ->join(' ')), + ])->values()->all(); } public function addNewToken() diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index dd634666b4..7c8cd8179f 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -41,14 +41,9 @@ search: '', page: 1, perPage: 10, - tokens: @js($tokens->map(fn ($token) => [ - 'id' => (string) $token->id, - 'search' => Str::lower(collect([$token->name, ...($token->abilities ?? [])]) - ->merge($token->scopes === null - ? ['All resources in the team'] - : collect($token->scopes)->map(fn ($scope) => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? null)->filter()) - ->join(' ')), - ])->values()), + get tokens() { + return this.$wire.tokenSearchIndex; + }, get filteredTokens() { const query = this.search.trim().toLowerCase(); diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index b8d7ab6b50..ca1540f6f9 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -27,8 +27,32 @@ test('api token permission flags are locked', function (string $property) { })->with([ 'root permission flag' => 'canUseRootPermissions', 'write permission flag' => 'canUseWritePermissions', + 'token search index' => 'tokenSearchIndex', ]); +test('token search index follows created and revoked tokens', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(ApiTokens::class) + ->set('form.description', 'deploy-token') + ->set('form.expiresInDays', 30) + ->set('form.permissions', ['read']) + ->call('addNewToken'); + + $token = $owner->tokens()->sole(); + expect($component->get('tokenSearchIndex'))->toBe([ + ['id' => (string) $token->id, 'search' => 'deploy-token read all resources in the team'], + ]); + + $component->call('revoke', $token->id); + + expect($component->get('tokenSearchIndex'))->toBe([]); +}); + test('member cannot tamper with root permission flag', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']);