mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(api-tokens): keep issued tokens visible and improve scope selection
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user