feat(api-tokens): edit a token in a modal that opens without a request

This commit is contained in:
Andras Bacsai
2026-10-30 14:27:58 +01:00
parent 548dec65b7
commit 18c5cf506b
10 changed files with 503 additions and 355 deletions
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Livewire\Forms;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Form;
/**
* The fields of the API token form. The page has one for a new token and one for the token in the edit modal.
*/
class ApiTokenForm extends Form
{
/**
* Expiry choice in the edit form that keeps the current expiry date.
*/
public const KEEP_EXPIRY = 'keep';
public ?string $description = null;
/**
* Untyped on purpose: Livewire skips hydrating null into typed properties, which
* would silently revert a "Never" (null) choice back to the default on the next request.
*
* @var int|string|null
*/
public $expiresInDays = 30;
public array $permissions = ['read'];
/**
* 'team' = every project and resource of the team (scopes null), 'selected' = only $selectedScopes.
*/
public string $scopeMode = 'team';
/**
* Picked scopes as "project:<uuid>", "environment:<uuid>", or "resource:<uuid>".
*
* @var list<string>
*/
public array $selectedScopes = [];
/**
* The edit form values of a token. The browser fills the edit modal with them, so opening it needs no request.
*
* @return array{id: int, description: string, permissions: list<string>, scopeMode: string, selectedScopes: list<string>, expiresInDays: string, keepLabel: string}
*/
public static function valuesOf(PersonalAccessToken $token): array
{
$permissions = $token->abilities ?? ['read'];
sort($permissions);
$currentExpiry = $token->expires_at?->format('Y-m-d') ?? 'Never';
return [
'id' => $token->id,
'description' => $token->name,
'permissions' => $permissions,
'scopeMode' => $token->scopes === null ? 'team' : 'selected',
'selectedScopes' => collect($token->scopes ?? [])->map(fn (array $scope) => "{$scope['scope']}:{$scope['uuid']}")->values()->all(),
'expiresInDays' => self::KEEP_EXPIRY,
'keepLabel' => 'Keep current ('.($token->expires_at?->isPast() ? 'expired '.$currentExpiry : $currentExpiry).')',
];
}
public function updatedPermissions($permissionToUpdate)
{
// Re-evaluate policies fresh — never trust stored snapshot booleans.
if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->getComponent()->dispatch('error', 'You do not have permission to use root permissions.');
$this->permissions = array_diff($this->permissions, ['root']);
return;
}
if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->getComponent()->dispatch('error', 'You do not have permission to use write permissions.');
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
return;
}
if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
$this->getComponent()->dispatch('error', 'You do not have permission to use deploy permissions.');
$this->permissions = array_diff($this->permissions, ['deploy']);
return;
}
if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
$this->getComponent()->dispatch('error', 'You do not have permission to use read:sensitive permissions.');
$this->permissions = array_diff($this->permissions, ['read:sensitive']);
return;
}
if ($permissionToUpdate == 'delete' && ! auth()->user()->can('useDeletePermissions', PersonalAccessToken::class)) {
$this->getComponent()->dispatch('error', 'You do not have permission to use delete permissions.');
$this->permissions = array_diff($this->permissions, ['delete']);
return;
}
if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) {
$this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read';
} else {
if (count($this->permissions) == 0) {
$this->permissions = ['read'];
}
}
sort($this->permissions);
}
}
+54 -132
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Security;
use App\Enums\AccessLevel;
use App\Enums\AccessScope;
use App\Livewire\Forms\ApiTokenForm;
use App\Models\Environment;
use App\Models\Project;
use App\Services\Access\AccessResolver;
@@ -20,43 +21,15 @@ class ApiTokens extends Component
{
use AuthorizesRequests;
/**
* Expiry choice in the edit form that keeps the current expiry date.
*/
private const KEEP_EXPIRY = 'keep';
public ApiTokenForm $form;
/**
* The token loaded in the form for editing; null when the form creates a new token.
* The token in the edit modal. The browser fills it from ApiTokenForm::valuesOf(), so opening the modal needs no request.
*/
#[Locked]
public ?int $editingTokenId = null;
public ?string $description = null;
/**
* Untyped on purpose: Livewire skips hydrating null into typed properties, which
* would silently revert a "Never" (null) choice back to the default on the next request.
*
* @var int|string|null
*/
public $expiresInDays = 30;
public ApiTokenForm $editForm;
public $tokens = [];
public array $permissions = ['read'];
/**
* 'team' = every project and resource of the team (scopes null), 'selected' = only $selectedScopes.
*/
public string $scopeMode = 'team';
/**
* Picked scopes as "project:<uuid>", "environment:<uuid>", or "resource:<uuid>".
*
* @var list<string>
*/
public array $selectedScopes = [];
public array $expirationOptions = [
7 => '7 days',
30 => '30 days',
@@ -90,10 +63,26 @@ class ApiTokens extends Component
]);
}
/**
* @param list<string> $selected
* @return list<string>
*/
private function withoutCoveredScopes(array $selected): array
{
$parents = collect($this->scopeOptions)->pluck('parents', 'value');
return array_values(array_filter(
array_unique($selected),
fn (string $value) => array_intersect($parents[$value] ?? [], $selected) === [],
));
}
/**
* Projects, environments, and resources of the current team that the user can see, for the scope picker.
*
* @return list<array{value: string, label: string, header?: bool}>
* Each option lists its "parents": the project and environment scopes that already include it.
*
* @return list<array{value: string, label: string, header?: bool, parents?: list<string>}>
*/
#[Computed]
public function scopeOptions(): array
@@ -101,13 +90,21 @@ class ApiTokens extends Component
$projects = Project::query()->where('team_id', currentTeam()->id)->orderBy('name')->get(['id', 'uuid', 'name']);
$environments = Environment::query()->whereIn('project_id', $projects->pluck('id'))->get(['id', 'uuid', 'name', 'project_id']);
$projectNames = $projects->pluck('name', 'id');
$projectUuids = $projects->pluck('uuid', 'id');
$environmentParents = $environments->mapWithKeys(fn (Environment $environment) => [
$environment->id => ["project:{$projectUuids[$environment->project_id]}", "environment:{$environment->uuid}"],
]);
$environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [
$environment->id => $projectNames[$environment->project_id].' / '.$environment->name,
]);
$resources = collect(AccessResolver::RESOURCE_MODELS)
->flatMap(fn (string $model) => $model::query()->whereIn('environment_id', $environments->pluck('id'))->get(['uuid', 'name', 'environment_id']))
->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name])
->map(fn ($resource) => [
'value' => "resource:{$resource->uuid}",
'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name,
'parents' => $environmentParents[$resource->environment_id],
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values();
@@ -115,7 +112,11 @@ class ApiTokens extends Component
...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]),
...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(),
...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]),
...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]])
...$environments->map(fn (Environment $environment) => [
'value' => "environment:{$environment->uuid}",
'label' => $environmentPaths[$environment->id],
'parents' => ["project:{$projectUuids[$environment->project_id]}"],
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(),
...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]),
...$resources->all(),
@@ -130,7 +131,7 @@ class ApiTokens extends Component
$this->canUseSensitivePermissions = auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class);
$this->canUseDeletePermissions = auth()->user()->can('useDeletePermissions', PersonalAccessToken::class);
$this->canUseTeamScope = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
$this->scopeMode = $this->canUseTeamScope ? 'team' : 'selected';
$this->form->scopeMode = $this->canUseTeamScope ? 'team' : 'selected';
$this->getTokens();
}
@@ -142,74 +143,24 @@ class ApiTokens extends Component
->get();
}
public function updatedPermissions($permissionToUpdate)
{
// Re-evaluate policies fresh — never trust stored snapshot booleans.
if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.');
$this->permissions = array_diff($this->permissions, ['root']);
return;
}
if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.');
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
return;
}
if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use deploy permissions.');
$this->permissions = array_diff($this->permissions, ['deploy']);
return;
}
if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use read:sensitive permissions.');
$this->permissions = array_diff($this->permissions, ['read:sensitive']);
return;
}
if ($permissionToUpdate == 'delete' && ! auth()->user()->can('useDeletePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use delete permissions.');
$this->permissions = array_diff($this->permissions, ['delete']);
return;
}
if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) {
$this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read';
} else {
if (count($this->permissions) == 0) {
$this->permissions = ['read'];
}
}
sort($this->permissions);
}
public function addNewToken()
{
try {
$this->authorize('create', PersonalAccessToken::class);
$teamId = currentTeam()->id;
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($teamId);
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($this->form, $teamId);
$expiresAt = filled($expiresInDays) ? now()->addDays((int) $expiresInDays) : null;
// One transaction, so a token never exists team-wide when saving its scopes fails.
$token = DB::transaction(function () use ($permissions, $expiresAt, $teamId, $storedScopes) {
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt, $teamId);
$token = auth()->user()->createToken($this->form->description, $permissions, $expiresAt, $teamId);
$token->accessToken->forceFill(['scopes' => $storedScopes])->save();
return $token;
});
auditLog('ui.api_token.created', [
'team_id' => $teamId,
'api_token_name' => $this->description,
'api_token_name' => $this->form->description,
'abilities' => $permissions,
'scopes' => $storedScopes,
'expires_at' => $expiresAt?->toIso8601String(),
@@ -223,53 +174,24 @@ class ApiTokens extends Component
}
/**
* Load a token into the form, so its description, permissions, access, and expiry can be changed.
* Save the edited token. The token value does not change, and the next request with it uses the new access.
*/
public function startEditing(int $id)
public function updateToken(int $id)
{
try {
$token = $this->findOwnToken($id);
$this->authorize('update', $token);
$this->resetValidation();
$this->editingTokenId = $token->id;
$this->description = $token->name;
$this->permissions = $token->abilities ?? ['read'];
sort($this->permissions);
$this->scopeMode = $token->scopes === null ? 'team' : 'selected';
$this->selectedScopes = collect($token->scopes ?? [])->map(fn (array $scope) => "{$scope['scope']}:{$scope['uuid']}")->values()->all();
$this->expiresInDays = self::KEEP_EXPIRY;
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function cancelEditing(): void
{
$this->resetValidation();
$this->reset('editingTokenId', 'description', 'expiresInDays', 'permissions', 'selectedScopes');
$this->scopeMode = $this->canUseTeamScope ? 'team' : 'selected';
}
/**
* Save the edited token. The token value does not change, and the next request with it uses the new access.
*/
public function updateToken()
{
try {
$token = $this->findOwnToken((int) $this->editingTokenId);
$this->authorize('update', $token);
$teamId = currentTeam()->id;
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($teamId, editing: true);
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($this->editForm, $teamId, editing: true);
$before = ['abilities' => $token->abilities, 'scopes' => $token->scopes, 'expires_at' => $token->expires_at?->toIso8601String()];
$token->forceFill([
'name' => $this->description,
'name' => $this->editForm->description,
'abilities' => $permissions,
'scopes' => $storedScopes,
]);
if ($expiresInDays !== self::KEEP_EXPIRY) {
if ($expiresInDays !== ApiTokenForm::KEEP_EXPIRY) {
$token->forceFill([
'expires_at' => filled($expiresInDays) ? now()->addDays((int) $expiresInDays) : null,
// A new expiry date needs a new expiration warning.
@@ -284,8 +206,9 @@ class ApiTokens extends Component
'before' => $before,
'after' => ['abilities' => $permissions, 'scopes' => $storedScopes, 'expires_at' => $token->expires_at?->toIso8601String()],
]);
$this->cancelEditing();
$this->editForm->reset();
$this->getTokens();
$this->dispatch('api-token-updated');
$this->dispatch('success', 'API token updated.');
} catch (\Exception $e) {
return handleError($e, $this);
@@ -298,14 +221,14 @@ class ApiTokens extends Component
*
* @return array{permissions: list<string>, scopes: list<array{scope: string, uuid: string}>|null, expiresInDays: int|string|null}
*/
private function validateTokenForm(int $teamId, bool $editing = false): array
private function validateTokenForm(ApiTokenForm $form, int $teamId, bool $editing = false): array
{
$expiryChoices = array_keys($this->expirationOptions);
if ($editing) {
$expiryChoices[] = self::KEEP_EXPIRY;
$expiryChoices[] = ApiTokenForm::KEEP_EXPIRY;
}
$validated = $this->validate([
$validated = $form->validate([
'description' => 'required|min:3|max:255',
'expiresInDays' => ['nullable', Rule::in($expiryChoices)],
'permissions' => 'required|array|min:1',
@@ -317,7 +240,8 @@ class ApiTokens extends Component
'selectedScopes.required' => 'Select at least one project, environment, or resource.',
]);
$permissions = array_values($validated['permissions']);
$scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null;
$form->selectedScopes = $this->withoutCoveredScopes($form->selectedScopes);
$scopes = $form->scopeMode === 'selected' ? $this->parseSelectedScopes($form->selectedScopes, $teamId) : null;
if (in_array('write:sensitive', $permissions, true) && ! auth()->user()->isAdminOfTeam($teamId)) {
throw new \Exception('You do not have permission to give tokens write:sensitive permissions.');
@@ -346,13 +270,14 @@ class ApiTokens extends Component
}
/**
* @param list<string> $selectedScopes
* @return list<Grant> scopes without levels; a token's levels are its abilities
*/
private function parseSelectedScopes(int $teamId): array
private function parseSelectedScopes(array $selectedScopes, int $teamId): array
{
$resolver = app(AccessResolver::class);
$scopes = [];
foreach (array_unique($this->selectedScopes) as $selected) {
foreach (array_unique($selectedScopes) as $selected) {
[$scope, $uuid] = explode(':', $selected, 2);
$grant = new Grant(AccessScope::from($scope), $uuid, []);
$layer = $resolver->resolve($teamId, [new Grant($grant->scope, $uuid, [AccessLevel::Read])]);
@@ -416,9 +341,6 @@ class ApiTokens extends Component
try {
$token = $this->findOwnToken($id);
$this->authorize('delete', $token);
if ($this->editingTokenId === $token->id) {
$this->cancelEditing();
}
$tokenName = $token->name;
$token->delete();
auditLog('ui.api_token.revoked', [
@@ -1,4 +1,13 @@
<div>
<div x-data="{
editingToken: null,
editToken(values) {
this.editingToken = values;
for (const field of ['description', 'expiresInDays', 'permissions', 'scopeMode', 'selectedScopes']) {
this.$wire.$set(`editForm.${field}`, values[field], false);
}
this.$dispatch('open-api-token-edit');
},
}">
<x-slot:title>
API Tokens | Coolify
</x-slot>
@@ -11,14 +20,6 @@
->values()
->push(['value' => '', 'label' => 'Never'])
->all();
$editingToken = $editingTokenId ? $tokens->firstWhere('id', $editingTokenId) : null;
if ($editingToken) {
$currentExpiry = $editingToken->expires_at?->format('Y-m-d') ?? 'Never';
array_unshift($expirationList, [
'value' => 'keep',
'label' => 'Keep current ('.($editingToken->expires_at?->isPast() ? 'expired '.$currentExpiry : $currentExpiry).')',
]);
}
@endphp
<div class="application-settings-form flex flex-col gap-6">
@@ -26,120 +27,44 @@
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="{{ $editingToken ? 'updateToken' : 'addNewToken' }}" id="api-token-form">
<x-application.settings-section :title="$editingToken ? 'Edit API token' : 'New API token'">
<form wire:submit="addNewToken">
<x-application.settings-section title="New API token">
<x-slot:actions>
@if ($editingToken)
<button type="button" wire:click="cancelEditing" class="button">
Cancel
</button>
<button type="submit" class="button button-highlighted">
Save changes
</button>
@else
<button type="submit"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Create token
</button>
@endif
<button type="submit" class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Create token
</button>
</x-slot:actions>
@if ($editingToken)
<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>
@endif
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="description" label="Description"
placeholder="CI deployment token" />
<x-forms.listbox id="expiresInDays" label="Expires in"
:options="$expirationList" />
</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">
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>
<x-helper helper="Only grant the abilities this token needs." />
</div>
<div class="relative" x-data="{ permissionsOpen: false }"
@click.outside="permissionsOpen = false" @keydown.escape.window="permissionsOpen = false">
<button type="button" class="listbox-trigger" @click="permissionsOpen = !permissionsOpen"
aria-haspopup="listbox" :aria-expanded="permissionsOpen">
<span class="truncate">
Selected permissions: {{ collect($permissions)->map(fn ($permission) => str($permission)->replace(':', ' ')->headline())->join(', ') }}
</span>
<x-reicon name="chevron-down" class="size-3.5 shrink-0 opacity-60" />
</button>
<div x-cloak x-show="permissionsOpen" x-transition.origin.top
class="listbox-panel top-full! mt-1! w-full!" role="listbox">
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-root" label="Root" fullWidth
wire:model.live="permissions" domValue="root"
helper="Full access to every API operation."
:checked="in_array('root', $permissions)" :disabled="!$canUseRootPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-write" label="Write" fullWidth
wire:model.live="permissions" domValue="write"
helper="Create and update resources."
:checked="in_array('write', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseWritePermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-delete" label="Delete" fullWidth
wire:model.live="permissions" domValue="delete"
helper="Delete resources and their settings (every DELETE request)."
:checked="in_array('delete', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseDeletePermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-deploy" label="Deploy" fullWidth
wire:model.live="permissions" domValue="deploy"
helper="Deploy, start, stop, and restart resources. With Write, also apply changes that run on servers immediately, such as scheduled tasks, proxy configuration, and imports."
:checked="in_array('deploy', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseDeployPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read" label="Read" fullWidth
wire:model.live="permissions" domValue="read"
helper="Read non-sensitive resource data."
:checked="in_array('read', $permissions)"
:disabled="in_array('root', $permissions)" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read-sensitive" label="Read sensitive data"
fullWidth wire:model.live="permissions" domValue="read:sensitive"
helper="Include secrets, logs, passwords, and Compose content."
:checked="in_array('read:sensitive', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseSensitivePermissions" />
</div>
</div>
</div>
</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">
<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>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="scopeMode" label="Resources" live :options="[
['value' => 'team', 'label' => 'All resources in the team', 'disabled' => !$canUseTeamScope],
['value' => 'selected', 'label' => 'Selected projects, environments, or resources'],
]" />
@if ($scopeMode === 'selected')
<x-forms.listbox id="selectedScopes" label="Selected" multiple searchable portal
placeholder="Choose projects, environments, or resources"
emptyText="No projects or resources available."
:options="$this->scopeOptions" />
@endif
</div>
</div>
@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'))
@@ -241,7 +166,37 @@
@if ($token->scopes === null)
All resources in the team
@else
{{ collect($token->scopes)->map(fn ($scope) => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? 'Unavailable '.($scope['scope'] ?? 'scope'))->join(', ') }}
@php
$tokenScopes = collect($token->scopes)
->map(fn ($scope) => [
'type' => $scope['scope'] ?? 'scope',
'label' => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? 'Unavailable '.($scope['scope'] ?? 'scope'),
])
->sortBy([
fn ($a, $b) => array_search($a['type'], ['project', 'environment', 'resource']) <=> array_search($b['type'], ['project', 'environment', 'resource']),
fn ($a, $b) => strnatcasecmp($a['label'], $b['label']),
]);
@endphp
{{-- The modal is wire:ignore, so a new key re-renders it when the scopes change. --}}
<x-modal-input title="Access of {{ $token->name }}"
wire:key="api-token-scopes-{{ $token->id }}-{{ md5(json_encode($token->scopes)) }}"
subtitle="This token can use only these projects, environments, and resources.">
<x-slot:content>
<button type="button"
class="max-w-full truncate underline decoration-dotted underline-offset-2 hover:text-black dark:hover:text-fg">
{{ $tokenScopes->countBy('type')->map(fn ($count, $type) => $count.' '.Str::plural($type, $count))->join(', ') }}
</button>
</x-slot:content>
<div
class="divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-white/[0.06] dark:border-white/[0.08]">
@foreach ($tokenScopes as $tokenScope)
<div class="flex min-h-8 items-center gap-3 px-3 py-1.5 text-[12px]">
<span class="w-20 shrink-0 text-[11px] capitalize text-neutral-500 dark:text-fg-dim">{{ $tokenScope['type'] }}</span>
<span class="min-w-0 flex-1 break-words text-black dark:text-fg">{{ $tokenScope['label'] }}</span>
</div>
@endforeach
</div>
</x-modal-input>
@endif
</div>
</div>
@@ -273,15 +228,6 @@
</div>
<div class="flex items-center justify-end gap-1">
@if (auth()->id() === $token->tokenable_id)
<button type="button" wire:click="startEditing({{ $token->id }})"
x-on:click="document.getElementById('api-token-form')?.scrollIntoView({ behavior: 'smooth' })"
class="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.07] dark:hover:text-fg">
Edit
</button>
<button type="button" wire:click="setPaused({{ $token->id }}, {{ $token->isPaused() ? 'false' : 'true' }})"
class="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.07] dark:hover:text-fg">
{{ $token->isPaused() ? 'Resume' : 'Pause' }}
</button>
<x-table.dropdown panel-class="w-36!" role="menu">
<x-slot:trigger>
<button type="button" class="icon-button" title="Token actions"
@@ -289,6 +235,16 @@
<x-reicon name="dots-horizontal" class="size-4" />
</button>
</x-slot:trigger>
<button type="button" role="menuitem"
x-on:click="close(); editToken(@js(App\Livewire\Forms\ApiTokenForm::valuesOf($token)))"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] transition-colors text-neutral-600 hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
Edit
</button>
<button type="button" role="menuitem" x-on:click="close()"
wire:click="setPaused({{ $token->id }}, {{ $token->isPaused() ? 'false' : 'true' }})"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] transition-colors text-neutral-600 hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
{{ $token->isPaused() ? 'Resume' : 'Pause' }}
</button>
<x-modal-confirmation title="Roll API Token?" buttonFullWidth
submitAction="roll({{ $token->id }})" :actions="[
'A new secret replaces this token. The current secret stops working at once.',
@@ -0,0 +1,141 @@
{{--
Fields of an ApiTokenForm. $form is the form property ('form' or 'editForm'). Everything that depends on
the values reads them from $wire, because the browser fills the edit form without a request.
--}}
<div class="grid gap-4 lg:grid-cols-2">
<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' : '' }}" />
</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">
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>
<x-helper helper="Only grant the abilities this token needs." />
</div>
<div class="relative" x-data="{
permissionsOpen: false,
get isRoot() {
return ($wire.{{ $form }}.permissions ?? []).includes('root');
},
get summary() {
return ($wire.{{ $form }}.permissions ?? [])
.map((permission) => permission.split(':').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' '))
.join(', ');
},
}" @click.outside="permissionsOpen = false" @keydown.escape.window="permissionsOpen = false">
<button type="button" class="listbox-trigger" @click="permissionsOpen = !permissionsOpen"
aria-haspopup="listbox" :aria-expanded="permissionsOpen">
<span class="truncate" x-text="'Selected permissions: ' + summary"></span>
<x-reicon name="chevron-down" class="size-3.5 shrink-0 opacity-60" />
</button>
<div x-cloak x-show="permissionsOpen" x-transition.origin.top
class="listbox-panel top-full! mt-1! w-full!" role="listbox">
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-root" label="Root" fullWidth
wire:model.live="{{ $form }}.permissions" domValue="root"
helper="Full access to every API operation."
:disabled="!$canUseRootPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-write" label="Write" fullWidth
wire:model.live="{{ $form }}.permissions" domValue="write"
helper="Create and update resources."
:disabled="!$canUseWritePermissions"
x-bind:disabled="isRoot || {{ $canUseWritePermissions ? 'false' : 'true' }}" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-delete" label="Delete" fullWidth
wire:model.live="{{ $form }}.permissions" domValue="delete"
helper="Delete resources and their settings (every DELETE request)."
:disabled="!$canUseDeletePermissions"
x-bind:disabled="isRoot || {{ $canUseDeletePermissions ? 'false' : 'true' }}" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-deploy" label="Deploy" fullWidth
wire:model.live="{{ $form }}.permissions" domValue="deploy"
helper="Deploy, start, stop, and restart resources. With Write, also apply changes that run on servers immediately, such as scheduled tasks, proxy configuration, and imports."
:disabled="!$canUseDeployPermissions"
x-bind:disabled="isRoot || {{ $canUseDeployPermissions ? 'false' : 'true' }}" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read" label="Read" fullWidth
wire:model.live="{{ $form }}.permissions" domValue="read"
helper="Read non-sensitive resource data."
x-bind:disabled="isRoot" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read-sensitive" label="Read sensitive data"
fullWidth wire:model.live="{{ $form }}.permissions" domValue="read:sensitive"
helper="Include secrets, logs, passwords, and Compose content."
:disabled="!$canUseSensitivePermissions"
x-bind:disabled="isRoot || {{ $canUseSensitivePermissions ? 'false' : 'true' }}" />
</div>
</div>
</div>
</div>
<div class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]" x-data="{
labels: @js($scopeLabels),
types: ['project', 'environment', 'resource'],
get isSelectedMode() {
return $wire.{{ $form }}.scopeMode === 'selected';
},
get sorted() {
return [...($wire.{{ $form }}.selectedScopes ?? [])].sort((a, b) =>
this.types.indexOf(a.split(':')[0]) - this.types.indexOf(b.split(':')[0])
|| (this.labels[a] ?? a).localeCompare(this.labels[b] ?? b));
},
{{-- A selected project already covers its environments and resources, and a selected environment its resources. --}}
coverScopes(listbox) {
const selected = listbox.selected;
const parentsOf = Object.fromEntries(listbox.options.map((option) => [option.value, option.parents ?? []]));
const coveredBy = (value) => (parentsOf[value] ?? []).find((parent) => selected.includes(parent));
listbox.options.forEach((option) => {
const parent = coveredBy(option.value);
option.disabled = Boolean(parent);
option.description = parent ? `Included in ${this.labels[parent]}` : null;
});
if (selected.some(coveredBy)) {
{{-- Set both sides of the entangle: a change on one side in the same tick as the click is lost. --}}
const kept = selected.filter((value) => !coveredBy(value));
listbox.value = kept;
$wire.{{ $form }}.selectedScopes = [...kept];
}
},
}">
<div class="mb-3 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>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="{{ $form }}.scopeMode" label="Resources" :options="[
['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"
placeholder="Choose projects, environments, or resources"
emptyText="No projects or resources available."
:options="$this->scopeOptions" x-effect="coverScopes($data)" />
</div>
</div>
<div x-show="isSelectedMode && sorted.length > 0"
class="mt-3 divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-white/[0.06] dark:border-white/[0.08]">
<template x-for="scope in sorted" :key="scope">
<div class="flex min-h-8 items-center gap-3 py-1 pl-3 pr-1.5 text-[12px]">
<span class="w-20 shrink-0 text-[11px] capitalize text-neutral-500 dark:text-fg-dim"
x-text="scope.split(':')[0]"></span>
<span class="min-w-0 flex-1 truncate text-black dark:text-fg"
x-text="labels[scope] ?? scope"></span>
<button type="button" class="icon-button shrink-0" title="Remove"
:aria-label="'Remove ' + (labels[scope] ?? scope)"
@click="$wire.{{ $form }}.selectedScopes = $wire.{{ $form }}.selectedScopes.filter((value) => value !== scope)">
<x-reicon name="x" class="size-3.5" />
</button>
</div>
</template>
</div>
</div>
+55 -43
View File
@@ -1,6 +1,7 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Forms\ApiTokenForm;
use App\Livewire\Security\ApiTokens;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@@ -74,6 +75,16 @@ function scopedTokenTestToken(User $user, Team $team, array $abilities, ?array $
return $token->plainTextToken;
}
/**
* Fill the edit form the way the browser does when the user clicks Edit.
*/
function scopedTokenTestEdit($component, PersonalAccessToken $token)
{
$values = collect(ApiTokenForm::valuesOf($token))->except(['id', 'keepLabel']);
return $component->set($values->mapWithKeys(fn ($value, $field) => ["editForm.{$field}" => $value])->all());
}
function scopedTokenTestMember(Team $team, ?array $access): User
{
$member = User::factory()->create();
@@ -289,10 +300,10 @@ describe('token form', function () {
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->set('description', 'shop deploys')
->set('permissions', ['deploy'])
->set('scopeMode', 'selected')
->set('selectedScopes', ["project:{$this->shop->uuid}"])
->set('form.description', 'shop deploys')
->set('form.permissions', ['deploy'])
->set('form.scopeMode', 'selected')
->set('form.selectedScopes', ["project:{$this->shop->uuid}"])
->call('addNewToken')
->assertHasNoErrors()
->assertNotDispatched('error');
@@ -309,10 +320,10 @@ describe('token form', function () {
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->set('description', 'foreign')
->set('permissions', ['read'])
->set('scopeMode', 'selected')
->set('selectedScopes', ["project:{$otherProject->uuid}"])
->set('form.description', 'foreign')
->set('form.permissions', ['read'])
->set('form.scopeMode', 'selected')
->set('form.selectedScopes', ["project:{$otherProject->uuid}"])
->call('addNewToken')
->assertDispatched('error');
@@ -323,22 +334,22 @@ describe('token form', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]);
$this->actingAs($member);
$form = Livewire::test(ApiTokens::class)->assertSet('scopeMode', 'selected');
$form = Livewire::test(ApiTokens::class)->assertSet('form.scopeMode', 'selected');
expect(collect($form->instance()->scopeOptions())->pluck('value')->filter(fn ($value) => str_starts_with($value, 'project:'))->values()->all())
->toBe(["project:{$this->shop->uuid}"]);
$form->set('description', 'too wide')
->set('permissions', ['write'])
->set('scopeMode', 'team')
$form->set('form.description', 'too wide')
->set('form.permissions', ['write'])
->set('form.scopeMode', 'team')
->call('addNewToken')
->assertDispatched('error');
$form->set('description', 'blog')
->set('scopeMode', 'selected')
->set('selectedScopes', ["project:{$this->blog->uuid}"])
$form->set('form.description', 'blog')
->set('form.scopeMode', 'selected')
->set('form.selectedScopes', ["project:{$this->blog->uuid}"])
->call('addNewToken')
->assertDispatched('error');
$form->set('description', 'shop')
->set('selectedScopes', ["project:{$this->shop->uuid}"])
$form->set('form.description', 'shop')
->set('form.selectedScopes', ["project:{$this->shop->uuid}"])
->call('addNewToken');
expect($member->tokens()->pluck('name')->all())->toBe(['shop']);
@@ -349,19 +360,20 @@ describe('token form', function () {
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->assertSet('editingTokenId', $token->id)
->assertSet('description', 'scoped-test')
->assertSet('permissions', ['read'])
->assertSet('scopeMode', 'team')
->assertSet('expiresInDays', 'keep')
->set('scopeMode', 'selected')
->set('selectedScopes', ["project:{$this->shop->uuid}"])
->call('updateToken')
expect(ApiTokenForm::valuesOf($token))->toMatchArray([
'description' => 'scoped-test',
'permissions' => ['read'],
'scopeMode' => 'team',
'expiresInDays' => 'keep',
]);
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.scopeMode', 'selected')
->set('editForm.selectedScopes', ["project:{$this->shop->uuid}"])
->call('updateToken', $token->id)
->assertHasNoErrors()
->assertNotDispatched('error')
->assertSet('editingTokenId', null);
->assertDispatched('api-token-updated');
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
->and(scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))->toBe(['shop']);
@@ -374,10 +386,9 @@ describe('token form', function () {
$token->forceFill(['expires_at' => $expiresAt, 'api_token_expiration_warning_sent_at' => now()])->save();
$this->actingAs($this->admin);
$form = Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->set('permissions', ['read'])
->call('updateToken')
$form = scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.permissions', ['read'])
->call('updateToken', $token->id)
->assertHasNoErrors();
$token->refresh();
@@ -386,9 +397,9 @@ describe('token form', function () {
->and($token->api_token_expiration_warning_sent_at)->not->toBeNull();
scopedTokenTestApi($plainToken, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden();
$form->call('startEditing', $token->id)
->set('expiresInDays', 90)
->call('updateToken')
scopedTokenTestEdit($form, $token)
->set('editForm.expiresInDays', 90)
->call('updateToken', $token->id)
->assertHasNoErrors();
$token->refresh();
@@ -402,11 +413,9 @@ describe('token form', function () {
$token = PersonalAccessToken::query()->sole();
$this->actingAs($member);
Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->assertSet('editingTokenId', $token->id)
->set('selectedScopes', ["project:{$this->blog->uuid}"])
->call('updateToken')
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.selectedScopes', ["project:{$this->blog->uuid}"])
->call('updateToken', $token->id)
->assertDispatched('error');
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]);
@@ -422,9 +431,12 @@ describe('token form', function () {
$this->actingAs($this->admin);
foreach (PersonalAccessToken::query()->get() as $token) {
Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->assertSet('editingTokenId', null);
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.description', 'taken over')
->call('updateToken', $token->id)
->assertStatus(404);
expect($token->fresh()->name)->toBe('scoped-test');
}
});
+1 -1
View File
@@ -85,7 +85,7 @@ it('lets users create API tokens for MCP while the REST API is disabled', functi
Livewire::test(ApiTokens::class)
->assertSee('API access is turned off')
->assertSee('Create token')
->set('description', 'MCP client')
->set('form.description', 'MCP client')
->call('addNewToken')
->assertHasNoErrors();
+13 -13
View File
@@ -26,9 +26,9 @@ describe('token creation with expiration', function () {
test('livewire component stores expires_at when expiresInDays set', function () {
Livewire::test(ApiTokens::class)
->set('description', 'test-token')
->set('expiresInDays', 7)
->set('permissions', ['read'])
->set('form.description', 'test-token')
->set('form.expiresInDays', 7)
->set('form.permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
@@ -42,9 +42,9 @@ describe('token creation with expiration', function () {
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
Livewire::test(ApiTokens::class)
->set('description', 'never-token')
->set('expiresInDays', null)
->set('permissions', ['read'])
->set('form.description', 'never-token')
->set('form.expiresInDays', null)
->set('form.permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
@@ -56,9 +56,9 @@ describe('token creation with expiration', function () {
test('livewire component rejects invalid expiresInDays value', function () {
Livewire::test(ApiTokens::class)
->set('description', 'bad-token')
->set('expiresInDays', 42)
->set('permissions', ['read'])
->set('form.description', 'bad-token')
->set('form.expiresInDays', 42)
->set('form.permissions', ['read'])
->call('addNewToken')
->assertDispatched('error');
@@ -67,10 +67,10 @@ describe('token creation with expiration', function () {
test('livewire component keeps Never (null) after an intermediate live update', function () {
Livewire::test(ApiTokens::class)
->set('description', 'never-token')
->set('expiresInDays', '')
->set('permissions', ['read', 'deploy'])
->set('permissions', ['read'])
->set('form.description', 'never-token')
->set('form.expiresInDays', '')
->set('form.permissions', ['read', 'deploy'])
->set('form.permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
@@ -48,9 +48,9 @@ test('member cannot create root token through tampered permissions payload', fun
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'pwned-root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->set('form.description', 'pwned-root-token')
->set('form.expiresInDays', 30)
->set('form.permissions', ['root'])
->call('addNewToken');
expect($member->tokens()->count())->toBe(0);
@@ -64,9 +64,9 @@ test('member can still create read token', function () {
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'read-token')
->set('expiresInDays', 30)
->set('permissions', ['read'])
->set('form.description', 'read-token')
->set('form.expiresInDays', 30)
->set('form.permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
@@ -120,9 +120,9 @@ test('owner can create root token', function () {
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'root-token')
->set('expiresInDays', 30)
->set('permissions', ['root'])
->set('form.description', 'root-token')
->set('form.expiresInDays', 30)
->set('form.permissions', ['root'])
->call('addNewToken')
->assertHasNoErrors();
@@ -140,9 +140,9 @@ test('owner can deselect root and falls back to read permission', function () {
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('permissions', ['root'])
->set('permissions', [])
->assertSet('permissions', ['read']);
->set('form.permissions', ['root'])
->set('form.permissions', [])
->assertSet('form.permissions', ['read']);
});
test('deploy permission can be combined with write permission', function (array $steps) {
@@ -152,13 +152,13 @@ test('deploy permission can be combined with write permission', function (array
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ApiTokens::class)->set('permissions', ['read']);
$component = Livewire::test(ApiTokens::class)->set('form.permissions', ['read']);
foreach ($steps as $permission) {
$component->set('permissions.'.count($component->get('permissions')), $permission);
$component->set('form.permissions.'.count($component->get('form.permissions')), $permission);
}
$component->assertSet('permissions', ['deploy', 'read', 'write']);
$component->assertSet('form.permissions', ['deploy', 'read', 'write']);
})->with([
'write then deploy' => [['write', 'deploy']],
'deploy then write' => [['deploy', 'write']],
@@ -172,9 +172,9 @@ test('owner can create token with read, write, and deploy permissions', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'deploy-write-token')
->set('expiresInDays', 30)
->set('permissions', ['read', 'write', 'deploy'])
->set('form.description', 'deploy-write-token')
->set('form.expiresInDays', 30)
->set('form.permissions', ['read', 'write', 'deploy'])
->call('addNewToken')
->assertHasNoErrors();
@@ -41,8 +41,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'invalid-token')
->set('permissions', $permissions)
->set('form.description', 'invalid-token')
->set('form.permissions', $permissions)
->call('addNewToken');
expect($this->member->tokens()->count())->toBe(0);
@@ -58,8 +58,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-root-token')
->set('permissions', ['root'])
->set('form.description', 'my-root-token')
->set('form.permissions', ['root'])
->call('addNewToken')
->assertDispatched('error');
@@ -71,8 +71,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-write-token')
->set('permissions', ['write'])
->set('form.description', 'my-write-token')
->set('form.permissions', ['write'])
->call('addNewToken')
->assertDispatched('error');
@@ -84,8 +84,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-deploy-token')
->set('permissions', ['deploy'])
->set('form.description', 'my-deploy-token')
->set('form.permissions', ['deploy'])
->call('addNewToken')
->assertDispatched('error');
@@ -97,8 +97,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-sensitive-token')
->set('permissions', ['read', 'read:sensitive'])
->set('form.description', 'my-sensitive-token')
->set('form.permissions', ['read', 'read:sensitive'])
->call('addNewToken')
->assertDispatched('error');
@@ -121,8 +121,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-read-token')
->set('permissions', ['read'])
->set('form.description', 'my-read-token')
->set('form.permissions', ['read'])
->call('addNewToken')
->assertNotDispatched('error');
@@ -135,8 +135,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-root-token')
->set('permissions', ['root'])
->set('form.description', 'my-root-token')
->set('form.permissions', ['root'])
->call('addNewToken')
->assertNotDispatched('error');
@@ -149,8 +149,8 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'owner-wildcard-token')
->set('permissions', ['*'])
->set('form.description', 'owner-wildcard-token')
->set('form.permissions', ['*'])
->call('addNewToken');
expect($this->owner->tokens()->count())->toBe(0);
+8 -4
View File
@@ -17,11 +17,12 @@ it('edits a token from the token list and saves the changes', function () {
$page = visit('/security/api-tokens')
->assertSee('New API token')
->click('button[aria-label="Token actions"]')
->click('button:visible:has-text("Edit")')
->assertSee('Edit API token')
->assertSee('Keep current (Never)')
->assertValue('description', 'ci-token')
->fill('description', 'deploy-token')
->assertValue('editForm.description', 'ci-token')
->fill('editForm.description', 'deploy-token')
->click('button:visible:has-text("Save changes")')
->wait(1)
->assertSee('New API token')
@@ -35,12 +36,13 @@ it('cancels editing without saving', function () {
loginAndSkipBoarding();
$page = visit('/security/api-tokens')
->click('button[aria-label="Token actions"]')
->click('button:visible:has-text("Edit")')
->fill('description', 'changed')
->fill('editForm.description', 'changed')
->click('button:visible:has-text("Cancel")')
->wait(1)
->assertSee('New API token')
->assertValue('description', '');
->assertValue('form.description', '');
expect($this->token->fresh()->name)->toBe('ci-token');
$page->screenshot(filename: 'api-token-edit-cancelled');
@@ -50,9 +52,11 @@ it('pauses and resumes a token from the token list', function () {
loginAndSkipBoarding();
$page = visit('/security/api-tokens')
->click('button[aria-label="Token actions"]')
->click('button:visible:has-text("Pause")')
->assertSee('API token paused.')
->assertSee('Paused')
->click('button[aria-label="Token actions"]')
->assertSee('Resume');
expect($this->token->fresh()->isPaused())->toBeTrue();