diff --git a/app/Livewire/Forms/ApiTokenForm.php b/app/Livewire/Forms/ApiTokenForm.php new file mode 100644 index 0000000000..dc5c309024 --- /dev/null +++ b/app/Livewire/Forms/ApiTokenForm.php @@ -0,0 +1,113 @@ +", "environment:", or "resource:". + * + * @var list + */ + 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, scopeMode: string, selectedScopes: list, 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); + } +} diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 5436cf1efa..0ffd0a6ce7 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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:", "environment:", or "resource:". - * - * @var list - */ - public array $selectedScopes = []; - public array $expirationOptions = [ 7 => '7 days', 30 => '30 days', @@ -90,10 +63,26 @@ class ApiTokens extends Component ]); } + /** + * @param list $selected + * @return list + */ + 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 + * Each option lists its "parents": the project and environment scopes that already include it. + * + * @return list}> */ #[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, scopes: list|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 $selectedScopes * @return list 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', [ diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index df909b9b07..8491ae5c34 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -1,4 +1,13 @@ -
+
API Tokens | Coolify @@ -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
@@ -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) -
- + + - @if ($editingToken) - - - @else - - @endif + - @if ($editingToken) -

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

- @endif - -
- - -
- -
-
-

Permissions

- -
-
- - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
-

Access

- -
-
- - @if ($scopeMode === 'selected') - - @endif -
-
+ @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')) @@ -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. --}} + + + + +
+ @foreach ($tokenScopes as $tokenScope) +
+ {{ $tokenScope['type'] }} + {{ $tokenScope['label'] }} +
+ @endforeach +
+
@endif
@@ -273,15 +228,6 @@
@if (auth()->id() === $token->tokenable_id) - - + + + + +
+ +
+
+

Permissions

+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+

Access

+ +
+
+ +
+ +
+
+
+ +
+
diff --git a/tests/Feature/Access/ScopedApiTokenTest.php b/tests/Feature/Access/ScopedApiTokenTest.php index 4847594612..fcc025619d 100644 --- a/tests/Feature/Access/ScopedApiTokenTest.php +++ b/tests/Feature/Access/ScopedApiTokenTest.php @@ -1,6 +1,7 @@ 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'); } }); diff --git a/tests/Feature/ApiDisabledCalloutTest.php b/tests/Feature/ApiDisabledCalloutTest.php index 4b2196e4ff..398b5f1ceb 100644 --- a/tests/Feature/ApiDisabledCalloutTest.php +++ b/tests/Feature/ApiDisabledCalloutTest.php @@ -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(); diff --git a/tests/Feature/ApiTokenExpirationTest.php b/tests/Feature/ApiTokenExpirationTest.php index 812e162160..b67ad19339 100644 --- a/tests/Feature/ApiTokenExpirationTest.php +++ b/tests/Feature/ApiTokenExpirationTest.php @@ -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(); diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index 43576c4364..b8d7ab6b50 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -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(); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php index 67af723a7e..abcad788fa 100644 --- a/tests/Feature/Security/ApiTokenCreationSecurityTest.php +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -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); diff --git a/tests/v4/Browser/ApiTokenEditTest.php b/tests/v4/Browser/ApiTokenEditTest.php index 6bfbc49e5e..9c61d43429 100644 --- a/tests/v4/Browser/ApiTokenEditTest.php +++ b/tests/v4/Browser/ApiTokenEditTest.php @@ -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();