From cdff76a9b5fa4c981e0788c9c5be0f04e491f10a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:27:42 +0100 Subject: [PATCH] feat(api-tokens): keep issued tokens visible and improve scope selection --- app/Livewire/Security/ApiTokens.php | 6 +- .../views/components/forms/listbox.blade.php | 6 + resources/views/components/navbar.blade.php | 6 +- .../security/settings-layout.blade.php | 12 +- .../livewire/security/api-tokens.blade.php | 144 ++++++++++-------- .../partials/api-token-fields.blade.php | 15 +- tests/v4/Browser/ApiTokenEditTest.php | 6 +- 7 files changed, 113 insertions(+), 82 deletions(-) diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 0ffd0a6ce7..7798203cb4 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -166,8 +166,10 @@ class ApiTokens extends Component 'expires_at' => $expiresAt?->toIso8601String(), ]); $this->getTokens(); + $this->form->reset(); + $this->form->scopeMode = $this->canUseTeamScope ? 'team' : 'selected'; // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. - session()->flash('token', $token->plainTextToken); + $this->dispatch('api-token-issued', token: $token->plainTextToken); } catch (\Exception $e) { return handleError($e, $this); } @@ -330,7 +332,7 @@ class ApiTokens extends Component 'api_token_name' => $token->name, ]); $this->getTokens(); - session()->flash('token', $plainTextToken); + $this->dispatch('api-token-issued', token: $plainTextToken); } catch (\Exception $e) { return handleError($e, $this); } diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index a9e5533050..3c36204bf0 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -24,6 +24,7 @@ 'searchPlaceholder' => 'Search…', 'searchEmptyText' => 'No matching options', 'allowCustom' => false, // true (with searchable) = the search text can be chosen as a custom value + 'countLabel' => null, // with multiple: always show a count such as "3 resources" instead of the selected labels ]) @php @@ -49,8 +50,10 @@ $selectedLabels = collect($options) ->filter(fn ($option) => empty($option['header']) && in_array((string) $option['value'], $selectedValues, true)) ->pluck('label'); + $countLabels = $countLabel ? [$countLabel, Illuminate\Support\Str::plural($countLabel)] : null; $multipleLabel = match (true) { $selectedLabels->isEmpty() => $placeholder, + $countLabels !== null => $selectedLabels->count().' '.$countLabels[$selectedLabels->count() === 1 ? 0 : 1], $selectedLabels->count() <= 2 => $selectedLabels->implode(', '), default => $selectedLabels->count().' selected', }; @@ -138,6 +141,9 @@ get current() { const labels = this.selectedLabels; if (labels.length === 0) return @js($placeholder); + @if ($countLabels) + return `${labels.length} ${labels.length === 1 ? @js($countLabels[0]) : @js($countLabels[1])}`; + @endif return labels.length <= 2 ? labels.join(', ') : `${labels.length} selected`; }, get title() { diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index fc1bd291bc..1034c28083 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -154,10 +154,10 @@ ]], ['label' => 'Keys & Tokens', 'icon' => 'keys', 'children' => [ ['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*'), 'visible' => $teamWide], - ['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)], - ['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)], - ['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)], ['label' => 'API Tokens', 'icon' => 'code', 'href' => route('security.api-tokens'), 'active' => request()->is('security/api-tokens')], + ['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'sectionStart' => true, 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)], + ['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)], + ['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'sectionStart' => true, 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)], ]], ['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => $teamWide && isCloud() && auth()->user()->isAdmin()], ['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags'), 'visible' => $teamWide], diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index 3985e6b413..819628d038 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -6,6 +6,12 @@ 'active' => request()->routeIs('security.private-key.*'), 'icon' => 'keys', ], + [ + 'label' => 'API Tokens', + 'route' => 'security.api-tokens', + 'active' => request()->routeIs('security.api-tokens'), + 'icon' => 'code', + ], auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class) ? [ 'label' => 'Cloud Tokens', 'route' => 'security.cloud-tokens', @@ -24,12 +30,6 @@ 'active' => request()->routeIs('security.cloud-init-scripts*'), 'icon' => 'file-content', ] : null, - [ - 'label' => 'API Tokens', - 'route' => 'security.api-tokens', - 'active' => request()->routeIs('security.api-tokens'), - 'icon' => 'code', - ], ])->filter(); @endphp diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 8491ae5c34..5cac1c1a58 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -1,5 +1,7 @@ +{{-- The new token value lives only in Alpine, so a Livewire update can not hide it or close its modal. --}}
+}" @api-token-issued.window="issuedToken = $event.detail.token"> API Tokens | Coolify @@ -26,71 +28,26 @@ - @can('create', App\Models\PersonalAccessToken::class) -
- - - - - - @include('livewire.security.partials.api-token-fields', [ - 'form' => 'form', - 'expiryOptions' => $expirationList, - 'inModal' => false, - ]) - -
- - - - -
-

- The token value does not change. Clients that use it get the new access on their next request. -

- - @include('livewire.security.partials.api-token-fields', [ - 'form' => 'editForm', - 'expiryOptions' => [['value' => 'keep', 'label' => 'Keep current'], ...$expirationList], - 'inModal' => true, - ]) -
- - - Cancel - Save changes - -
- @endcan - - @if (session()->has('token')) - -
-

- This value will not be shown again after you leave this page. -

-
- - -
-
-
- @endif - + @can('create', App\Models\PersonalAccessToken::class) + + + + @endcan
@if ($tokens->count() > 1) -
-
+
+
+
@endif @@ -297,5 +256,68 @@
+ + @can('create', App\Models\PersonalAccessToken::class) + + + +
+ @include('livewire.security.partials.api-token-fields', [ + 'form' => 'form', + 'expiryOptions' => $expirationList, + 'inModal' => true, + ]) +
+ + + Cancel + Create token + +
+ + + + +
+

+ The token value does not change. Clients that use it get the new access on their next request. +

+ + @include('livewire.security.partials.api-token-fields', [ + 'form' => 'editForm', + 'expiryOptions' => [['value' => 'keep', 'label' => 'Keep current'], ...$expirationList], + 'inModal' => true, + ]) +
+ + + Cancel + Save changes + +
+ @endcan + + + + +
+

+ Copy the token now. It is not shown again after you close this window. +

+
+ + +
+
+ + + Done + +
diff --git a/resources/views/livewire/security/partials/api-token-fields.blade.php b/resources/views/livewire/security/partials/api-token-fields.blade.php index 8ac715cdc7..f73257ee75 100644 --- a/resources/views/livewire/security/partials/api-token-fields.blade.php +++ b/resources/views/livewire/security/partials/api-token-fields.blade.php @@ -6,11 +6,11 @@ + x-effect="{{ $form === 'editForm' ? 'options[0].label = editingToken?.keepLabel ?? options[0].label' : '' }}" />
-
-
+
+

Permissions

@@ -77,7 +77,7 @@
-
-
+

Access

@@ -115,8 +115,9 @@ ['value' => 'team', 'label' => 'All resources in the team', 'disabled' => !$canUseTeamScope], ['value' => 'selected', 'label' => 'Selected projects, environments, or resources'], ]" /> -
- + diff --git a/tests/v4/Browser/ApiTokenEditTest.php b/tests/v4/Browser/ApiTokenEditTest.php index 9c61d43429..487e9219cf 100644 --- a/tests/v4/Browser/ApiTokenEditTest.php +++ b/tests/v4/Browser/ApiTokenEditTest.php @@ -16,7 +16,7 @@ it('edits a token from the token list and saves the changes', function () { loginAndSkipBoarding(); $page = visit('/security/api-tokens') - ->assertSee('New API token') + ->assertSee('Issued tokens') ->click('button[aria-label="Token actions"]') ->click('button:visible:has-text("Edit")') ->assertSee('Edit API token') @@ -25,7 +25,7 @@ it('edits a token from the token list and saves the changes', function () { ->fill('editForm.description', 'deploy-token') ->click('button:visible:has-text("Save changes")') ->wait(1) - ->assertSee('New API token') + ->assertSee('Issued tokens') ->assertSee('deploy-token'); expect(PersonalAccessToken::query()->sole()->name)->toBe('deploy-token'); @@ -41,7 +41,7 @@ it('cancels editing without saving', function () { ->fill('editForm.description', 'changed') ->click('button:visible:has-text("Cancel")') ->wait(1) - ->assertSee('New API token') + ->assertSee('Issued tokens') ->assertValue('form.description', ''); expect($this->token->fresh()->name)->toBe('ci-token');