feat(api-tokens): keep issued tokens visible and improve scope selection

This commit is contained in:
Andras Bacsai
2026-10-30 14:27:58 +01:00
parent 18c5cf506b
commit cdff76a9b5
7 changed files with 113 additions and 82 deletions
+4 -2
View File
@@ -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);
}
@@ -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() {
+3 -3
View File
@@ -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],
@@ -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
@@ -1,5 +1,7 @@
{{-- The new token value lives only in Alpine, so a Livewire update can not hide it or close its modal. --}}
<div x-data="{
editingToken: null,
issuedToken: null,
editToken(values) {
this.editingToken = values;
for (const field of ['description', 'expiresInDays', 'permissions', 'scopeMode', 'selectedScopes']) {
@@ -7,7 +9,7 @@
}
this.$dispatch('open-api-token-edit');
},
}">
}" @api-token-issued.window="issuedToken = $event.detail.token">
<x-slot:title>
API Tokens | Coolify
</x-slot>
@@ -26,71 +28,26 @@
<x-api-disabled-callout
message="REST API requests are rejected. Tokens still authenticate the MCP endpoint when the MCP server is enabled." />
@can('create', App\Models\PersonalAccessToken::class)
<form wire:submit="addNewToken">
<x-application.settings-section title="New API token">
<x-slot:actions>
<button type="submit" class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Create token
</button>
</x-slot:actions>
@include('livewire.security.partials.api-token-fields', [
'form' => 'form',
'expiryOptions' => $expirationList,
'inModal' => false,
])
</x-application.settings-section>
</form>
<x-modal-input title="Edit API token" :wireIgnore="false" :closeOutside="false"
@open-api-token-edit.window="modalOpen = true" @api-token-updated.window="modalOpen = false">
<x-slot:content><span></span></x-slot:content>
<form x-on:submit.prevent="$wire.updateToken(editingToken.id)" id="api-token-edit-form">
<p class="mb-4 text-[12px] text-neutral-500 dark:text-fg-dim">
The token value does not change. Clients that use it get the new access on their next request.
</p>
@include('livewire.security.partials.api-token-fields', [
'form' => 'editForm',
'expiryOptions' => [['value' => 'keep', 'label' => 'Keep current'], ...$expirationList],
'inModal' => true,
])
</form>
<x-slot:footer>
<x-forms.button @click="modalOpen = false">Cancel</x-forms.button>
<x-forms.button isHighlighted type="submit" form="api-token-edit-form">Save changes</x-forms.button>
</x-slot:footer>
</x-modal-input>
@endcan
@if (session()->has('token'))
<x-application.settings-section title="Copy your token">
<div class="flex flex-col gap-3">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
This value will not be shown again after you leave this page.
</p>
<div class="relative">
<input type="text" value="{{ session('token') }}" readonly
class="input w-full pr-12! font-mono text-[12px] text-black dark:text-fg">
<x-copy-button :value="session('token')" label="Copy token"
class="absolute top-1/2 right-2 -translate-y-1/2" />
</div>
</div>
</x-application.settings-section>
@endif
<x-application.settings-section title="Issued tokens" flush>
@can('create', App\Models\PersonalAccessToken::class)
<x-slot:actions>
<button type="button" class="button button-highlighted" @click="$dispatch('open-api-token-create')">
<x-reicon name="plus" class="size-3.5" />
New token
</button>
</x-slot:actions>
@endcan
<div x-data="{
search: '',
page: 1,
perPage: 10,
tokens: @js($tokens->map(fn ($token) => [
'id' => (string) $token->id,
'search' => strtolower($token->name . ' ' . implode(' ', $token->abilities ?? [])),
'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 filteredTokens() {
const query = this.search.trim().toLowerCase();
@@ -120,8 +77,8 @@
}
}">
@if ($tokens->count() > 1)
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<div class="relative max-w-sm">
<div class="flex items-center gap-2 border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<div class="relative w-full max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.150ms="search" x-on:input="page = 1" type="search"
@@ -133,6 +90,8 @@
<x-reicon name="x" class="size-3" />
</button>
</div>
<x-helper label="Search help"
helper="Search by token name, ability, or the name of a project, environment, or resource that the token can use." />
</div>
@endif
@@ -297,5 +256,68 @@
</div>
</x-application.settings-section>
</div>
@can('create', App\Models\PersonalAccessToken::class)
<x-modal-input title="New API token" :wireIgnore="false" :closeOutside="false"
@open-api-token-create.window="modalOpen = true" @api-token-issued.window="modalOpen = false">
<x-slot:content><span></span></x-slot:content>
<form wire:submit="addNewToken" id="api-token-create-form" class="lg:w-2xl">
@include('livewire.security.partials.api-token-fields', [
'form' => 'form',
'expiryOptions' => $expirationList,
'inModal' => true,
])
</form>
<x-slot:footer>
<x-forms.button @click="modalOpen = false">Cancel</x-forms.button>
<x-forms.button isHighlighted type="submit" form="api-token-create-form">Create token</x-forms.button>
</x-slot:footer>
</x-modal-input>
<x-modal-input title="Edit API token" :wireIgnore="false" :closeOutside="false"
@open-api-token-edit.window="modalOpen = true" @api-token-updated.window="modalOpen = false">
<x-slot:content><span></span></x-slot:content>
<form x-on:submit.prevent="$wire.updateToken(editingToken.id)" id="api-token-edit-form" class="lg:w-2xl">
<p class="mb-4 text-[12px] text-neutral-500 dark:text-fg-dim">
The token value does not change. Clients that use it get the new access on their next request.
</p>
@include('livewire.security.partials.api-token-fields', [
'form' => 'editForm',
'expiryOptions' => [['value' => 'keep', 'label' => 'Keep current'], ...$expirationList],
'inModal' => true,
])
</form>
<x-slot:footer>
<x-forms.button @click="modalOpen = false">Cancel</x-forms.button>
<x-forms.button isHighlighted type="submit" form="api-token-edit-form">Save changes</x-forms.button>
</x-slot:footer>
</x-modal-input>
@endcan
<x-modal-input title="Copy your token" :closeOutside="false"
@api-token-issued.window="modalOpen = true" x-effect="if (!modalOpen) issuedToken = null">
<x-slot:content><span></span></x-slot:content>
<div class="flex flex-col gap-3 lg:w-2xl">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Copy the token now. It is not shown again after you close this window.
</p>
<div class="relative">
<input type="text" :value="issuedToken" readonly @focus="$el.select()"
class="input w-full pr-12! font-mono text-[12px] text-black dark:text-fg">
<x-copy-button resolve="issuedToken" label="Copy token"
class="absolute top-1/2 right-2 -translate-y-1/2" />
</div>
</div>
<x-slot:footer>
<x-forms.button isHighlighted @click="modalOpen = false">Done</x-forms.button>
</x-slot:footer>
</x-modal-input>
</x-security.settings-layout>
</div>
@@ -6,11 +6,11 @@
<x-forms.input required id="{{ $form }}.description" label="Description"
placeholder="CI deployment token" />
<x-forms.listbox id="{{ $form }}.expiresInDays" label="Expires in" :options="$expiryOptions"
x-effect="{{ $inModal ? 'options[0].label = editingToken?.keepLabel ?? options[0].label' : '' }}" />
x-effect="{{ $form === 'editForm' ? 'options[0].label = editingToken?.keepLabel ?? options[0].label' : '' }}" />
</div>
<div class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<div class="mb-3 flex items-center gap-2">
<div class="mt-4 border-t border-neutral-200 pt-3 dark:border-white/[0.08]">
<div class="mb-2 flex items-center gap-2">
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>
<x-helper helper="Only grant the abilities this token needs." />
</div>
@@ -77,7 +77,7 @@
</div>
</div>
<div class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]" x-data="{
<div class="mt-4 border-t border-neutral-200 pt-3 dark:border-white/[0.08]" x-data="{
labels: @js($scopeLabels),
types: ['project', 'environment', 'resource'],
get isSelectedMode() {
@@ -106,7 +106,7 @@
}
},
}">
<div class="mb-3 flex items-center gap-2">
<div class="mb-2 flex items-center gap-2">
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Access</h4>
<x-helper helper="A limited token sees only the selected projects, environments, and resources, and cannot use team-level endpoints such as servers, keys, or notifications." />
</div>
@@ -115,8 +115,9 @@
['value' => 'team', 'label' => 'All resources in the team', 'disabled' => !$canUseTeamScope],
['value' => 'selected', 'label' => 'Selected projects, environments, or resources'],
]" />
<div x-show="isSelectedMode">
<x-forms.listbox id="{{ $form }}.selectedScopes" label="Selected" multiple searchable :portal="! $inModal"
<div class="min-w-0">
<x-forms.listbox id="{{ $form }}.selectedScopes" label="Selected" multiple searchable :portal="! $inModal" countLabel="resource"
x-bind:disabled="!isSelectedMode"
placeholder="Choose projects, environments, or resources"
emptyText="No projects or resources available."
:options="$this->scopeOptions" x-effect="coverScopes($data)" />
+3 -3
View File
@@ -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');