fix(api-tokens): keep token search in sync after create, edit, and revoke

Closes #12465

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 10:29:24 +01:00
co-authored by Claude Opus 5.5
parent 9e64998d68
commit 59481c7bb6
3 changed files with 56 additions and 9 deletions
+29 -1
View File
@@ -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<array{id: string, search: string}>
*/
#[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<string, string>
*/
private function scopeLabels(): Collection
{
return collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label', 'value');
}
/**
* @param list<string> $selected
* @return list<string>
@@ -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()
@@ -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();
@@ -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']);